Problem
Teams often start by naming services. That makes deployment topology feel like architecture, while the actual product responsibilities remain unclear. The result is an interface that is difficult to explain and even harder to change.
Summary
Define the unit of responsibility first. Model the inputs it accepts, the decisions it owns, and the signals it publishes. Only then decide whether it needs to become a package, a process, or a separate service.
Boundary sketch
flowchart LR
Client[Client intent] --> Policy[Policy boundary]
Policy --> Runtime[Runtime boundary]
Runtime --> Store[Persistence adapter]
Policy --> Events[Published events]The important observation is that policy can change without forcing the runtime or persistence adapter to change. This is the seam to protect.
Implementation
Start with a small interface that describes the decision, not the storage operation.
export type AccessRequest = {
actorId: string;
resource: "workspace" | "project";
action: "read" | "write";
};
export function decideAccess(request: AccessRequest) {
return request.action === "read" ? "allow" : "evaluate";
}
Keep adapters outside the policy module. The policy should be testable with values in, values out.
Trade-offs
This approach introduces a little more naming work at the beginning. It pays back when a product gains more entry points, because policy remains legible rather than being copied into handlers.
Conclusion
Service boundaries are an implementation choice. Responsibility boundaries are the durable engineering artifact.