Node.js frameworks make it easy to add routes. They do not decide where business rules belong, which module owns data, or how dependencies should flow. As an application grows, those structural decisions determine whether a change stays local or ripples through controllers, repositories, queues, and tests.
NestJS offers modules, dependency injection, guards, pipes, and other conventions that can support a clear design. A disciplined Express application can create the same boundaries with routers, constructors, and explicit exports. Framework choice matters less than whether the boundaries are real.
Organize around capabilities, not technical folders
A common early layout groups every controller, service, and model together by technical type. It is easy to navigate while the codebase is small, but related behavior becomes scattered as features multiply. A capability-oriented structure keeps the public API, use cases, domain rules, and adapters for one area close together:
src/
fees/
application/
domain/
infrastructure/
http/
fees.module.ts
students/
identity/
The exact folders are optional. The important rule is that a module has a named responsibility and a small public surface. Internal repositories and provider adapters should not become convenient imports for unrelated modules.
A synthetic fee-module boundary
Suppose a fee capability owns charges and concessions. Other parts of the application need to post a charge, but they should not update fee tables directly. A small application interface can express that boundary:
export type PostCharge = {
studentId: string;
amount: number;
reference: string;
};
export interface FeeService {
postCharge(command: PostCharge): Promise<{ chargeId: string }>;
}
The module can depend on ports that describe what its use cases need:
export interface ChargeLedger {
findByReference(reference: string): Promise<Charge | null>;
save(charge: Charge): Promise<void>;
}
A PostgreSQL adapter implements ChargeLedger. The application service enforces the duplicate-reference rule and controls the transaction. The HTTP controller validates transport input, invokes FeeService, and maps known outcomes to the API contract. It does not contain fee policy or issue direct SQL.
In NestJS, the module might export only the FeeService token and keep repository providers private:
@Module({
controllers: [FeeController],
providers: [
PostChargeService,
{ provide: CHARGE_LEDGER, useClass: PostgresChargeLedger },
],
exports: [PostChargeService],
})
export class FeesModule {}
This is not protection by itself. TypeScript paths can still bypass the intended API unless import rules, package boundaries, or team review enforce it. The module metadata makes intent visible; engineering discipline makes it true.
With Express, construct the same graph explicitly:
const ledger = new PostgresChargeLedger(db);
const feeService = new PostChargeService(ledger);
app.use("/fees", createFeeRouter(feeService));
Neither version is inherently more architectural. Both can become tightly coupled if modules import each other’s internals, use a global service locator, or share an unowned collection of database models.
Keep dependency direction deliberate
Business rules should not depend directly on HTTP, a queue library, or a database client. Those technologies are delivery and persistence details. Direct dependencies are sometimes pragmatic, but they should be conscious decisions rather than the default caused by imports.
A useful direction is:
HTTP / jobs -> application use cases -> domain rules
|
v
declared ports
^
|
database / provider adapters
The inner code defines the interface it needs; the adapter satisfies it. This allows a use case to be tested without starting the framework and keeps provider-specific failure handling near the adapter.
Do not create an interface for every class. Introduce a port where a dependency crosses a meaningful boundary, requires a test substitute, or has multiple plausible implementations. Excess abstraction can obscure a simple workflow as effectively as excessive coupling.
Decide who owns transactions
Repositories that each start and commit their own transaction make multi-step invariants difficult to preserve. The application use case usually has enough context to define the transaction boundary: load the relevant state, enforce the rule, save changes, and record an outgoing event or job atomically where required.
Cross-module operations need extra care. Avoid a “shared transaction” that lets any module mutate any table. Prefer one module owning the state change and exposing an operation. If another capability must react asynchronously, define delivery, idempotency, and reconciliation behavior rather than assuming a message is delivered exactly once.
Transaction ownership should be visible in tests. A unit test can verify business decisions using an in-memory fake, but it cannot prove SQL constraints or isolation behavior. Integration tests should exercise the real adapter, migrations, uniqueness constraints, and rollback behavior.
Separate transport validation from business rules
DTO validation can check shape: required fields, formats, and size limits. It should not be the only home for rules such as “a concession cannot exceed the remaining commitment” or “a reference cannot be posted twice.” Those rules belong in the capability that owns the state and must hold whether the request arrives through HTTP, a queue, or an administrative job.
Similarly, authentication middleware can establish identity, but authorization decisions may require current domain state. A guard that checks a role claim is not sufficient when access depends on institution, student, or workflow status.
Test the boundary at several levels
Use focused tests for different evidence:
- Domain tests exercise calculations and invariants without I/O.
- Use-case tests verify orchestration and outcomes with controlled ports.
- Adapter tests verify SQL, serialization, and provider contracts.
- HTTP tests verify validation, authentication integration, status codes, and error shapes.
- Architecture checks detect imports that bypass module boundaries.
Mocking every internal class can freeze implementation details and make refactoring painful. Prefer testing a module’s public behavior and substitute only meaningful external boundaries.
Recognize when a modular monolith is enough
Explicit modules do not require separate deployments. A modular monolith keeps local calls, one deployment, and potentially one database while preserving ownership in code. That can be a strong fit when workflows are closely related and the organization does not need independent scaling or release cycles.
Splitting services adds network uncertainty, versioned contracts, distributed tracing, deployment coordination, and data-consistency decisions. Extract a service when there is evidence for independent ownership, scaling, security isolation, or lifecycle—not because a framework makes microservices available.
More structure also has a cost. A small application may need only a router, service, and repository. Add boundaries in proportion to domain complexity and change risk. Boilerplate that carries no ownership or dependency meaning is ceremony.
An architecture review can assess module responsibilities, dependency flow, transaction boundaries, and extraction pressure before a rewrite becomes the default answer.
NestJS can help teams express structure consistently. Express can remain maintainable with explicit composition and import discipline. The lasting advantage comes from clear ownership, narrow interfaces, and dependencies that point toward the business rules—not from the logo on the framework.