Modular monolith boundaries
Start a module with an explicit public contract
Define the operation other features need, then hide the rules and persistence behind it. This gives the module a boundary that can survive internal changes.
In this article
Begin with a real caller
Suppose a support screen needs to change an order's delivery address. Write the command and possible outcomes before exposing repository methods. The caller needs to know whether the change succeeded, the order was stale or its state no longer permits the operation.
Use identifiers and values that describe the business request. Avoid passing database entities whose internal fields invite the caller to depend on persistence details.
Keep caller identity in a trusted application context. A command can name the target, but it should not invent the user's authority to change it.
type AddressChangeResult =
| { status: 'changed'; orderVersion: number }
| { status: 'stale' }
| { status: 'not-permitted' }
| { status: 'already-dispatched' }
type ChangeAddress = {
orderId: string
expectedVersion: number
address: DeliveryAddress
}These types illustrate a contract, not a complete implementation. Runtime validation, access checks and concurrency control still belong in the execution path.
Keep internals behind the entry point
Place the module's domain rules and repository behind its public API. Export only the types and operations other features need. Configure dependency checks to reject imports from internal paths where the project tooling supports it.
Do not create a shared folder as a shortcut around every boundary. Shared identifiers or utility types can be reasonable, but a shared mutable entity model often couples modules back to one another's storage assumptions.
Keep dependencies directional. If order handling calls billing and billing also reaches into order internals, inspect whether a coordinating workflow or a clearer contract would remove the cycle.
Put the invariant beside the write
The address-change operation loads the current order, checks access and eligibility, and commits under the chosen version or transaction rule. The rule must hold for every caller, including background jobs and administrative paths.
Return a business outcome rather than leaking a database exception as the normal contract. Unexpected infrastructure failures still need diagnostic information, but callers should not interpret vendor error strings to decide whether dispatch has begun.
Test the public operation with permitted, stale and ineligible orders. Add an architecture check that a separate feature cannot import the repository directly.
Integrate one caller and inspect the dependency
Move the support screen to the public operation and remove its direct persistence access. Check that the caller no longer needs to understand the order table's shape.
Then change an internal implementation detail and verify the caller still compiles and behaves correctly. This is a practical test of the boundary's value. A module is useful when it lets its owner change internal structure while preserving the business contract other features rely on.
Primary sources
Microsoft Learn: common web application architecturesPostgreSQL: transaction isolationReferences checked 11 September 2026.