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.

published: reading time: 9 min read author: GeekWorkBench
Quick Summary

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 safe 5xx responses 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, and error_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, or utils package 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

1. What is separation of concerns in a backend service?

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.

2. How do you know if a module boundary is useful?

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.

3. Should every backend module have an interface?

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.

4. When should a module become a separate service?

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

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 #typescript #code-organization

Backend Configuration, Environments, and Dependencies

Learn how backend services load configuration, separate development from production, validate settings, and manage dependencies without leaking secrets.

#backend #configuration #deployment

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.

#backend #background-jobs #worker-pools