Enforced Boundaries
The patterns in the rest of the backend docs are only useful if they're actually
followed. ForgeStack doesn't rely on discipline or code review for that. It
ships a set of custom ESLint rules (in eslint/rules/) that fail the build
when an architectural rule is broken. The architecture is executable.
What gets enforced
All of them run in npm run lint and fail it on a violation. These are the
rule ids registered in the root eslint.config.mjs:
| Rule | What it enforces |
|---|---|
| hexagonal-architecture | layer dependency direction (a no-restricted-imports config, not a named rule) |
bounded-context/no-cross-imports | no imports between bounded contexts (except shared) |
cqrs/handler-collocation | handlers import their command/query/event from the same folder |
cqrs/no-direct-decorators | no raw @CommandHandler / @QueryHandler / @EventsHandler |
handler/naming | handler class names end in the suffix that matches their file |
entity-domain/no-direct-entity-mutation | aggregate state changes only through its own methods |
value-object/naming | .vo.ts files hold value objects, and value objects live in .vo.ts files |
aggregate/must-extend-base and seven more */must-extend-base rules | each building block extends its base class (table below) |
Layer boundaries
hexagonal-architecture (a no-restricted-imports config, not a single named
rule) enforces the dependency direction, layer by layer:
domain may import only from domain; application may import from domain
and application; infrastructure may import from domain and infrastructure;
interfaces may import from application and interfaces. A domain file that
imports from infrastructure is an error, and so is a controller that reaches
past the command and query buses into domain objects. The core stays pure.
Bounded-context isolation
bounded-context/no-cross-imports stops one bounded context from reaching into
another's internals. Keyed off the @bc/* path alias: from inside @bc/auth
you may import @bc/auth/... and @bc/shared/..., but not
@bc/notifications/.... Contexts collaborate through integration events, not
imports.
// eslint/rules/no-cross-bc-imports.mjs (essence)
const BC_PATH_REGEX = /bounded-contexts\/([^/]+)\//;
const BC_ALIAS_REGEX = /^@bc\/([^/]+)/;
// flag when importing BC !== current BC (allowing 'shared')CQRS collocation & naming
cqrs/handler-collocation: a*.command-handler.tsmust import its command from the same directory (likewise queries; domain-event handlers import the event fromdomain/.../events). Keeps each use case self-contained.handler/naming: handler classes must end with the right suffix for their file:_CommandHandlerin*.command-handler.ts,_QueryHandlerin*.query-handler.ts,_DomainEventHandlerin*.domain-event-handler.ts,_IntegrationEventHandlerin*.integration-event-handler.ts.cqrs/no-direct-decorators: NestJS's@CommandHandler,@QueryHandlerand@EventsHandlerdecorators are forbidden outside the framework's own CQRS code. ExtendBase_CommandHandler(Command),Base_QueryHandler(Query)orBase_DomainEventHandler(Event)instead: the base registers the handler and adds the tracing, metrics and logging a raw decorator would silently skip.
Base-class conformance
A family of rules guarantees the building blocks actually extend the right base, so the cross-cutting behaviour (events, tracing, metrics, transactions) is never accidentally missing:
| Rule | File suffix | Must extend / implement |
|---|---|---|
aggregate/must-extend-base | *.aggregate.ts | SharedAggregate |
aggregate-dto/must-extend-base | *.dto.ts under domain/aggregates/ | SharedAggregateDTO |
value-object/naming | *.vo.ts | the value-object interface (and VOs must live in *.vo.ts) |
command-handler/must-extend-base | *.command-handler.ts | Base_CommandHandler(Command) |
query-handler/must-extend-base | *.query-handler.ts | Base_QueryHandler(Query)<Response>() |
domain-event/must-extend-base | *.domain-event.ts | Base_DomainEvent |
domain-event-handler/must-extend-base | domain-event handlers | Base_DomainEventHandler(Event) |
integration-event/must-extend-base | *.integration-event.ts | Base_IntegrationEvent |
repository/must-extend-base | *.repository.ts (the port interface) | Repository<Aggregate, Id> |
Aggregate immutability
entity-domain/no-direct-entity-mutation forbids assigning directly to
properties of a SharedAggregate instance from outside its own methods. State must change
through intention-revealing behaviour (user.canAuthenticate(),
outbox.markAsProcessed()) so invariants always run.
Why enforce instead of advise
Three reasons:
- Boundaries erode silently. A single "just this once" import from domain into infrastructure is how layered architectures rot. A rule turns that into a red build, not a future refactor.
- It's the best documentation. New contributors (and AI agents) learn the rules by hitting them, with a precise message pointing at the fix.
- It keeps the promises true. Other docs say "the domain is pure" and "contexts talk only through events". These rules are what make those statements reliably true, not aspirational.
Extending the rules
The rules live in eslint/rules/ as small, standalone .mjs
files. When you add a new convention to your fork, add a rule for it. That's how the architecture
stays enforceable as it grows.
That completes the backend tour. Continue with the Frontend.