Backend Separation of Concerns and Module Boundaries
Learn how to set backend module boundaries, keep dependencies pointed in one direction, and avoid coupling that makes routine changes risky.
Backend module boundaries give HTTP handling, business rules, and storage clear responsibilities so routine changes stay local. An order request example shows how an application flow can depend on a storage interface while a database adapter handles persistence. The guide covers when to add boundaries, when they create needless indirection, and how to handle failures, security, and observability at module edges.
Backend Separation of Concerns and Module Boundaries
When a small backend grows, one file often ends up handling HTTP parsing, business rules, database queries, and logging. The first few features still ship. Then a change to an order rule breaks a report, tests need a database just to check validation, and developers are afraid to touch the shared utils folder.
Separation of concerns gives those responsibilities clear homes. A module boundary makes the split concrete: it defines what a part of the system owns and what other parts may ask it to do. The goal is not to create the most folders. It is to make common changes local and failures easier to trace.
Introduction
This article follows an order request through a modular backend, then weighs the design’s trade-offs, failure modes, and operating concerns. It also shows when a boundary helps and when another layer would only add indirection.
Trace a request through the boundaries
For an order request, the HTTP adapter validates transport-specific details, the application flow coordinates the use case, and the domain code applies the order rules. A storage adapter performs database work. The response goes back through the HTTP adapter.
flowchart LR
Client[HTTP client] --> Route[HTTP route]
Route --> UseCase[Create order use case]
UseCase --> Rules[Order rules]
UseCase --> StorePort[Order storage interface]
StorePort --> SqlAdapter[SQL storage adapter]
SqlAdapter --> Database[(Database)]
UseCase --> Route
Route --> Client
The application flow can depend on an interface for storage, while the SQL adapter implements it. That lets the order behavior run against an in-memory fake in a unit test. It also keeps HTTP concepts such as status codes out of the order module. For a related view of how this plays out at service scale, see microservices vs monolith. Internal module boundaries can provide useful separation without introducing network calls.
A small implementation
Here is a deliberately plain TypeScript example. The route owns HTTP parsing and response mapping. The use case owns the operation’s sequence. The storage interface expresses what the use case needs, not how a database happens to provide it.
type NewOrder = { customerId: string; itemIds: string[] };
type Order = NewOrder & { id: string; status: "pending" };
interface OrderStore {
insert(order: NewOrder): Promise<Order>;
}
async function createOrder(input: NewOrder, store: OrderStore): Promise<Order> {
if (input.itemIds.length === 0) {
throw new Error("An order needs at least one item");
}
return store.insert(input);
}
async function postOrder(
request: Request,
store: OrderStore,
): Promise<Response> {
const input = (await request.json()) as NewOrder;
try {
const order = await createOrder(input, store);
return Response.json(order, { status: 201 });
} catch (error: unknown) {
if (
error instanceof Error &&
error.message === "An order needs at least one item"
) {
return Response.json({ error: "invalid_order" }, { status: 400 });
}
throw error;
}
}
In production code, use a dedicated validation result or error type rather than matching an error message. The useful part here is the seam: createOrder does not import the web framework or database client. This also complements functions, modules, and error handling, which covers smaller function and error boundaries.
When to use this structure
Separate responsibilities when a part of the system has its own rules, changes on a different cadence, needs a distinct test strategy, or is owned by a different team. Start with the boundaries visible in current pain: repeated rule logic, imports that form cycles, or a test that needs infrastructure unrelated to what it checks.
Do not add an interface and an adapter for every function by default. If a feature is small, used in one place, and has no meaningful variation or testing need, a direct call may be clearer. A boundary that only renames a method adds navigation without reducing coupling. It is also fine to begin with a modular monolith; a process boundary brings operational costs that a folder boundary does not. The architecture trade-offs between monoliths and microservices are a useful reminder to make that jump for a concrete reason.
Trade-offs to decide deliberately
| Choice | Helps with | Costs or risks |
|---|---|---|
| Feature-oriented modules | Keeps a feature’s rules and storage near each other | Shared behavior can be duplicated unless ownership is clear |
| Layer-oriented modules | Makes framework and infrastructure code easy to locate | A single feature may require edits across many folders |
| Public interfaces at boundaries | Allows callers to ignore implementation details and simplifies focused tests | Too many interfaces create ceremony and hide simple control flow |
| Shared common module | Centralizes stable, genuinely cross-cutting behavior | A catch-all module becomes a dependency that every feature can change |
Pick the smallest structure that keeps likely changes local. Revisit it when the actual change patterns contradict the original choice.
Production failures and how boundaries help
Boundaries do not prevent every bug. They make specific failures easier to contain:
- A storage change leaks into business rules. The domain imports an ORM model or query builder. Keep database records inside the adapter and map them to application types at the edge.
- Circular imports create fragile startup behavior. Two modules reach into each other’s internals. Move the shared contract to a stable owner or change the call direction so one module owns the workflow.
- A database outage gets reported as bad input. The route catches every exception and returns
400. Map expected client errors explicitly; let infrastructure failures become safe5xxresponses and preserve their cause in server logs. - A slow dependency ties up requests. Put timeouts and cancellation at the adapter boundary, and expose the operation’s latency separately from total request time.
Boundary and tracing checklist
- Ownership and contracts: Name an owner for each public module contract. Review caller compatibility when its inputs or behavior change, and watch for consumer failures after rollout.
- Dependency direction: Keep business rules dependent on stable interfaces, with adapters implementing them. Review boundary changes for new reverse dependencies or callers reaching into private types.
- Cross-module traces: Propagate a correlation ID through each module call. Record the boundary operation, outcome, and duration with fields such as
order_id,dependency, anderror_code; track storage latency and failures with bounded labels. Do not log request bodies or credentials.
Security at module edges
Treat each boundary as a place to validate assumptions. Parse and validate untrusted HTTP input before it reaches business logic. Check authorization close to the operation that uses the protected resource, rather than relying on a route name or UI state. Storage adapters should use parameterized queries and least-privilege credentials.
Keep secrets in the runtime configuration layer, never in module defaults or logs. Return stable public errors to clients and keep internal stack traces and SQL details on the server. If a module emits events, define which fields are safe to publish; copying an entire database record into an event can expose fields that consumers do not need.
Pitfalls that make boundaries worse
- Creating one module per class, even when the classes always change together.
- Making every function public, which turns implementation details into permanent contracts.
- Building a
common,shared, orutilspackage without an owner or clear inclusion rule. - Duplicating a module’s business rules in a controller because its interface is awkward.
- Splitting a monolith into services before the team can operate deployments, retries, and partial failures.
If a boundary is hard to explain in one sentence, it may not represent a real responsibility yet. Start with the change that hurts, make the smallest seam that would contain it, and adjust after the next few features.
Quick Recap Checklist
- Can I name the responsibility this module owns?
- Can callers use it without knowing its tables, framework, or private types?
- Do dependencies point toward stable rules, with infrastructure at the edges?
- Can I test the rule without starting unrelated infrastructure?
- Are errors mapped at the boundary that understands them?
- Do logs and metrics identify which boundary failed without exposing sensitive data?
- Does each interface remove real coupling, or only add another hop?
Interview Questions
It means assigning different reasons to change to distinct parts of the system. For example, HTTP response formatting belongs at the transport edge, while a pricing rule belongs with the pricing behavior. The boundary should let one change happen without requiring callers to understand unrelated details.
Look at real changes and tests. A useful boundary keeps related changes together, hides implementation details, or lets a rule be tested without starting infrastructure it does not need. If callers still reach into internals, or the boundary only forwards every call unchanged, it may not be helping.
No. An interface is useful when it separates a policy from a replaceable or external detail, such as a business operation from its database adapter. For a stable helper used in one place, a direct function may be simpler and just as testable.
Consider a service boundary when independent deployment, scaling, security, or team ownership solves a specific problem. The team must also be ready to operate network timeouts, retries, versioned contracts, and partial failure. A code-level boundary is cheaper, so it is often a sensible first step.
Further Reading
- Functions, modules, and error handling explores how to keep function responsibilities clear and handle errors at the right boundary.
- Backend configuration, environments, and dependencies covers configuration ownership and dependency boundaries across environments.
- Microservices vs. monolith compares code-level modularity with the operational costs of splitting services.
- TypeScript Handbook: Modules explains how module imports and exports define source-level dependencies.
- Martin Fowler: Microservice Trade-Offs discusses module boundaries and the costs that come with service boundaries.
Conclusion
Good module boundaries keep related rules together and keep unrelated changes apart. Start with responsibilities that already have different reasons to change, expose only the operations callers need, and let infrastructure details stay at the edges. If the structure makes routine work slower, change it; the folder tree is there to help the team, not to win an architecture diagram.
Category
Related Posts
Functions, Modules, and Error Handling
Learn to shape backend functions and modules, handle failures clearly, and build services that are easier to test, operate, and change.
Backend Configuration, Environments, and Dependencies
Learn how backend services load configuration, separate development from production, validate settings, and manage dependencies without leaking secrets.
Background Jobs, Scheduling, and Worker Pools
Design background jobs and worker pools with bounded concurrency, safe retries, scheduling, and production checks that keep slow work out of request paths.