An architectural doctrine for NestJS projects: a breakdown of typical codebase degradation scenarios and the structural constraints that keep them from emerging as the feature set grows.
In parts 1–2 we watched a simple signUp turn, under the pressure of requirements, into a god-service of hundreds of lines that knew about a dozen modules at once. In part 3 we worked through how forwardRef and circular dependencies are born from this naturally — that very tangle that can no longer be untangled. In part 4 we designed an FBCA architecture from scratch — domain / use-case / infrastructure / presentation with layers, external ports, and Result-based error handling — and showed how the same signUp looks inside it.
Part 5 is a parallel universe of the same plot. The same business requirements that hit feature-based in parts 1–2 now hit the FBCA codebase from part 4: the sign-up handler grows the same external services (anti-fraud, referrals, partners, analytics), and the Users module grows the same features (profile, settings, privacy, stats). Let's see what changes in form, what changes in content, and why the dependency graph stays acyclic even after a multi-fold growth. At the end — a formal justification in the language of graph theory: why the DAG invariant, the coupling bound, and the constant cost of an increment aren't a property of the architecture in the sense of "convenient," but its mathematical content.
Remember the set of requirements from part 2 — the one under whose weight AuthService.signUp turned into an eight-hundred-line god service, surrounded by six neighboring services and a single backward forwardRef? The same list now arrives at the FBCA codebase:
- a partner program with bloggers and streamers
- different monetization models (revenue share, bonuses, tiers)
- a more complex anti-fraud (multiple scenarios and scoring)
- extended analytics (marketing, product, finance)
- additional checks and limits for referrals
The business logic has to remain the same — otherwise the comparison isn't fair. But the form of the code and of the dependency graph — let's see how those change. The note on transactions from part 1 continues to apply here and onward — we're examining decomposition, not transactional integrity.
export type SignUpInput = {
email: string;
password: string;
referralCode?: string;
adSourceCode?: string;
ip?: string;
deviceId?: string;
};
@Injectable()
export class SignUpHandler {
constructor(
private readonly bonusExternalService: BonusExternalService,
private readonly usersExternalService: UsersExternalService,
private readonly partnerExternalService: PartnerExternalService,
private readonly adSourceExternalService: AdSourceExternalService,
private readonly referralExternalService: ReferralExternalService,
private readonly analyticsExternalService: AnalyticsExternalService,
private readonly antiFraudExternalService: AntiFraudExternalService,
) {}
async run(
input: SignUpInput,
): Promise<Result<SignUpResult, SignUpErrorCode>> {
const { email, password, referralCode, adSourceCode, ip, deviceId } = input;
// anti-fraud
const checkAntiFraudResult =
await this.antiFraudExternalService.checkSignUp({ ip, deviceId });
if (checkAntiFraudResult.isErr()) {
return err("SIGN_UP_ANTI_FRAUD_FAILED");
}
if (!checkAntiFraudResult.value.allowed) {
return err("SIGN_UP_ANTI_FRAUD_REJECTED");
}
// make sure the user isn't already registered — before any side effects
const findUserResult =
await this.usersExternalService.getUserByEmail(email);
if (findUserResult.isErr()) {
return err("SIGN_UP_GET_USER_FAILED");
}
if (findUserResult.value) {
return err("SIGN_UP_USER_ALREADY_EXISTS");
}
// traffic source / A/B
const applyAdSourceResult =
await this.adSourceExternalService.applyAdSource(adSourceCode);
if (applyAdSourceResult.isErr()) {
return err("SIGN_UP_AD_SOURCE_FAILED");
}
// referral / partner (the module decides what kind of code this is)
const resolveReferralResult = await this.referralExternalService.resolve(
referralCode,
email,
);
if (resolveReferralResult.isErr()) {
return err("SIGN_UP_REFERRAL_RESOLVE_FAILED");
}
const createUserResult = await this.usersExternalService.createUser({
email,
password,
adSource: applyAdSourceResult.value,
ip,
deviceId,
});
if (createUserResult.isErr()) {
return err("SIGN_UP_CREATE_USER_FAILED");
}
const referral = resolveReferralResult.value;
const user = createUserResult.value;
// bonuses
if (referral?.kind === "user") {
const giveReferralBonusResult =
await this.bonusExternalService.giveReferralBonus(referral.ownerId);
if (giveReferralBonusResult.isErr()) {
return err("SIGN_UP_BONUS_FAILED");
}
const createReferralResult =
await this.referralExternalService.createReferral(
referral.ownerId,
user.id,
);
if (createReferralResult.isErr()) {
return err("SIGN_UP_REFERRAL_CREATE_FAILED");
}
}
if (referral?.kind === "partner") {
const processPartnerResult =
await this.partnerExternalService.processPartner(referral);
if (processPartnerResult.isErr()) {
return err("SIGN_UP_PARTNER_FAILED");
}
const givePartnerRewardResult =
await this.bonusExternalService.givePartnerReward(
processPartnerResult.value,
);
if (givePartnerRewardResult.isErr()) {
return err("SIGN_UP_PARTNER_REWARD_FAILED");
}
const trackPartnerRewardResult =
await this.analyticsExternalService.trackPartnerReward(
processPartnerResult.value,
);
if (trackPartnerRewardResult.isErr()) {
return err("SIGN_UP_ANALYTICS_FAILED");
}
}
// final analytics
const trackRegistrationResult =
await this.analyticsExternalService.trackRegistration({
userId: user.id,
source: applyAdSourceResult.value?.code,
ip,
});
if (trackRegistrationResult.isErr()) {
return err("SIGN_UP_ANALYTICS_FAILED");
}
return ok({ id: user.id, email: user.email });
}
}
As you can observe, the logic, the queries, and the algorithms have remained exactly the same. At which point the reader naturally has these questions:
- Why did we even introduce
feature-based-cleanif the business logic is identical to the FB version? - There's more code now, not less. Where's the promised readability?
- What do I, as a developer, gain right now if the result is the same steps, just with more folders and abstractions?
These questions look like a refutation of the whole idea. In reality they highlight what we already fixed at the close of part 4: FBCA isn't about today's code, it's about the code two or three sprints from now. If you compare only the current snapshot, FB and FBCA look about the same — the difference is that one of them has points where you can stop, and the other doesn't. That difference isn't visible until architectural load shows up. And that's exactly what we'll now see in action.
Let's look at how the Users module developed over time under the rules of feature-based-clean. While the sign-up handler was growing external services, the Users module itself was developing too — the frontend asked for the profile, marketing for stats, analytics for counters. By the current moment, Users is no longer that small module with two use-cases that we designed in part 4. Now it's a full-fledged domain:
- fetching the user profile
- updating the profile (bio, avatar, username)
- updating account settings
- account privacy (public / private)
- fetching basic stats (followers, follows)
- managing user preferences (language, theme, etc.)
- fetching the current user (
meendpoint)
src/modules/users/
├── domain/ # User, UserProfile, UserSettings, UserPrivacy, ...
│
├── use-case/
│ ├── presentation/ # Scenarios for UsersController
│ │ ├── get-profile/
│ │ ├── update-profile/
│ │ ├── update-account-settings/
│ │ ├── update-privacy/
│ │ ├── update-preferences/
│ │ ├── get-user-stats/
│ │ └── get-current-user/
│ │
│ └── external/ # Scenarios for other modules (via the external port)
│ ├── create-user/ # ← Auth
│ ├── get-user-by-email/ # ← Auth
│ ├── check-user-exists/ # ← Likes, Comments, Follows
│ ├── get-following-ids/ # ← Feed
│ ├── can-view-content/ # ← Feed
│ ├── can-receive-notification/ # ← Notifications
│ ├── get-public-user-info/ # ← Comments, Likes
│ ├── is-searchable/ # ← Search
│ ├── is-user-blocked/ # ← Moderation, AntiFraud
│ └── get-user-status/ # ← various modules
│
├── infrastructure/
│ └── repositories/
│ ├── user/
│ ├── user-profile/
│ ├── user-settings/
│ ├── user-privacy/
│ ├── user-preferences/
│ ├── user-stats/
│ └── user-session/
│
├── external/ # The port for other modules (Auth, Feed, Comments, Search, ...)
│
└── presentation/ # UsersController + DTO
As we can see, the need for a big "split by responsibility" refactoring has gone away. In feature-based, when UsersService reaches critical mass — say, eight hundred lines and fifteen methods across different user subsystems — the time comes for a major rewrite. Break it apart into UserProfileService, UserSettingsService, UserPrivacyService. That's serious work: rewrite the calls, untangle the cycles, update the tests, reassemble the modules. A month, a month and a half at minimum — and during all of that the system has to keep running in production.
In FBCA, that point simply doesn't exist. When the PM comes in with "we need privacy settings" — that's use-case/presentation/update-privacy/ next to the already-existing update-profile/ and update-account-settings/. When the DBA says "it would be nice to move user_sessions into a separate table" — that's infrastructure/repositories/user-session/ next to user/ and user-profile/. Each new artifact lands next to the old ones, shifting nothing. There are more folders, but this isn't sprawl — it's horizontal growth that reads the same way five files used to read.
And when the count of use-cases reaches twenty or thirty and your eyes start scattering across one directory, "splitting by responsibility" reduces to one mechanical operation: lay out the ready-made Lego pieces into thematic folders. update-profile/, update-bio/, update-avatar/ move into use-case/presentation/profile/. update-privacy/, update-account-settings/ — into use-case/presentation/security/. No dependencies to untangle, no cycles, no Nest DI reconfiguration. The PR may turn out fat — but only because import paths are changing; you don't edit a single line of logic in the process.
Let's now look at the dependency graph that emerged as a result of FBCA.
Now let's look at the FB graph before cycles start there.
FB is obviously losing here: many neighboring services have started pulling code out of UsersService, and it seems that if you remove this dependency, everything will be fine.
That's self-deception. The arrows aren't lazy development — they're a reflection of the business: the referral program really does need to know who invited whom; anti-fraud needs to see the account history; analytics needs to enrich events with profile data. You can't remove the dependencies — the modules will stop working.
The same needs exist in FBCA — there are just as many arrows above, but they point at UsersExternalService. The difference is that each such arrow isn't a "universal key to all of Users," but a concrete authorization: one neighbor is allowed to call only getUserByEmail, another — only isUserBlocked. In FB, anyone with UsersService in their DI graph has access to the full hub API and may, tomorrow, call from there a method no one ever agreed on. In FBCA, that's an obvious contract violation: what isn't in external/ doesn't exist for the neighbor.
Even if you remove the internal services' dependency on UsersService, set up separate repositories for each entity, and instill the discipline of "service doesn't call service" — that won't save you for long.
The point is that a service as a unit of code organization is built in a way that it can't help but expand. One class — many methods. Today UserProfileService.updateBio() and updateAvatar(). Tomorrow also getProfileMetadata(), recalculateCompletenessScore(), markAsViewed(). A sprint later it has twenty public methods, and any importer gets access to all of them at once. The problem isn't that Profile depends on Users — the problem is that depending on a class automatically grants access to the whole class.
That's where the use-case in the FBCA sense comes in — and it is not a "business scenario" in the usual sense of the word. A use-case here is a structural unit: one folder, one handler, one operation. update-profile/ isn't "all the work on the profile," it's specifically "update bio/avatar/username." To add "fetch profile metadata," you need a new folder get-profile-metadata/ next to it, not a new method in the existing handler. The form itself doesn't allow operations to accumulate at one point.
That's what removes the problem. When an importer needs one operation, they import exactly that — not the whole service. When a new operation appears, it sits next to the others without shifting them. The dependency graph stops being pulled toward hubs, because there's nowhere for the hubs to pull to: every node is an atomic unit.
In part 3 we already worked through how forwardRef is born from a single backward import. On the graph, that looks like this.
Can this be proven mathematically?
In short — the statement "the architecture doesn't degrade" is, by itself, not mathematical. Degradation is defined by people, and any structure can be broken with deliberate disregard. But structural properties, under which degradation is hindered in a specific way, can be formally proven. Let's go through three such properties.
1. The DAG invariant
The system's dependency graph splits into two levels — inside a single module and between modules. We'll show that both levels are DAGs.
The intra-module graph. Let's introduce:
-
V_M— the set of classes of moduleM(handlers, repositories, domain types, presentation, the external port) -
E_M ⊆ V_M × V_M— the intra-module "depends on" edges -
L: V_M → ℕ— the layer function within a single module:domain = 0,infrastructure = 1,use-case = 2,external = 3,presentation = 4
Claim. If for every edge (x, y) ∈ E_M we have L(x) > L(y), then the intra-module graph (V_M, E_M) contains no cycle. Technically, such a graph is called a DAG, Directed Acyclic Graph.
Proof. Suppose the graph contains a cycle x₁ → x₂ → ... → xₙ → x₁. Then from the condition:
L(x₁) > L(x₂) > ... > L(xₙ) > L(x₁)
which gives L(x₁) > L(x₁) — a contradiction. No cycle can exist. What does this step mean? A cycle is a path that brings us back to the starting point. But every edge along this path strictly lowers the value of L: from the first class to the second (L(x₁) > L(x₂)), from the second to the third, and so on. By the end of the path, we have to be strictly lower than the starting value — and at the same time at the starting point itself, where L again equals the starting value. "Strictly lower" and "the same" simultaneously — impossible. That is the contradiction.
In FBCA this function exists by construction: every file lives in one of the layers, and the direction of imports is set by convention. External acts as a facade over the use-cases of its own module — it imports handlers (L(external) = 3 > L(use-case) = 2), not the other way around.
The inter-module graph. The intra-module L function says nothing about the ties between modules — for those, a separate rule applies.
-
V_inter— the set of modules in the system - Edges — "module X imports code of module Y"
Rule. Any import from module X into module Y is permitted only through the external/ port of module Y. The external itself doesn't import other modules — this is its constructive property: it declares the surface of its own module and delegates into the internal handlers of that same module, and nothing more.
Consequence. A cycle M₁ → M₂ → ... → Mₙ → M₁ would require every module in the chain to import the external of the next one. But if M₁.external imports nothing from outside its own module, there is simply nowhere for the edge Mₙ → M₁ to come from — that's a violation of the rule itself. A cycle between modules is topologically impossible.
Result: the system graph is a DAG both inside each module (through the layer function) and between modules (through the external-only rule).
This is the formalization of the phrase "dependencies flow in only one direction." In feature-based, there's neither L nor an external rule: services can import each other in any direction, so cycles are topologically possible — and that's what materializes in practice (the very case from part 3).
What this means in practice (and where forwardRef fits in)
The clearest way to explain what a DAG is — imagine you walk through the graph on foot, following the arrows.
In FBCA, every step strictly lowers you down the layers: from presentation to use-case, from use-case to infrastructure, from infrastructure to domain. Once you reach domain, there are no arrows out. The walk ends naturally.
In a graph with a cycle, no such "end" exists. You go along an arrow, then a second, then a third — and discover that you've returned to where you started. And you'll go around the same loop again, and again, and so on to infinity. The only way to stop is to separately remember "I've already been here."
That's exactly what NestJS does via forwardRef. When the DI container encounters a circular dependency, it can't resolve it "honestly" — it would have to create A in order to pass it to B, but B is required to create A. forwardRef tells the container: "hold off on instantiating right now, I'll come back to this connection later." That is, the framework marks for itself "already seen," so as not to go into a loop.
In an FBCA structure, that crutch isn't needed at all. The graph has nowhere to loop — every walk along the arrows has a natural end at the domain layer. NestJS resolves dependencies in one pass, without marks and without forwardRef.
2. The coupling bound
A module's coupling = the size of its public API.
In feature-based:
API(M) = the union of all public methods of all services exported from M
This size grows automatically with every new method of any service.
In FBCA:
API(M) = the public methods of M.external
The size grows only when an operation is explicitly added to the external/ port.
Consequence. At equal functionality, |API_FBCA(M)| ≤ |API_FB(M)|, because in FB every public method of a service automatically becomes part of the module's API, while in FBCA — only what's explicitly laid out in external/.
In information-theoretic terms: coupling, as the mutual information between consumer and module, is bounded by the size of the API. The FBCA structure rigidly limits the surface through which accidental coupling is possible.
3. The cost of change
Let F be the feature being added.
In FB, when a method is added to a god service S:
- Writing the new logic:
O(1)lines - Hidden expansion of the surface:
O(|consumers(S)|)consumers now have access to the new operation - Possible regressions: proportional to the number of consumers
In FBCA, when a new operation is added:
- Creating a new folder
<verb-noun>/with a handler and a module:O(1)lines - Hidden expansion of the surface:
O(0)— no existing handler changes, no external service grows
Qualitatively:
IncrementalCost_FB(F) ∈ O(|consumers|)
IncrementalCost_FBCA(F) ∈ O(1)
This is the very "cost flatness" we were talking about earlier: in FB, the price of each next feature grows with the number of existing consumers; in FBCA, it stays a constant.
Under what conditions this holds
All three claims are conditional on three invariants:
- The function
Lis monotonic — no "upward" edges: a handler doesn't call presentation, a repository doesn't call a use-case. - Every module exposes only the
external/port. Internal handlers and repositories are exported nowhere outward. - A use-case doesn't call a neighboring use-case. Common logic descends downward — into the repository or the domain — rather than spreading sideways.
Without these conditions, the proofs don't work. That is, what can be strictly stated is only the following:
When the layer and external-port invariants are observed, the dependency graph is a DAG, the API size is bounded by the size of the external surface, and the cost of adding a feature is constant.
What is proven is concrete structural properties under the observance of concrete rules. These properties (the absence of cycles, bounded coupling, constant cost of an increment) constitute the real content of the term "doesn't degrade."
Let's now visualize the scaling process of the project and the Users module — first, let's simplify the graph visualization and take a snapshot of the current state.
Let's imagine a year of production use has gone by. The set of use-cases in the graph below isn't a preventive decomposition, it's the accumulated answer to concrete requirements and incidents: each group emerged as a response to a specific PM request, an incident, or a regulatory requirement. A quick rundown by group, with the source of each.
Presentation (what the user does with their own account):
-
Profile. The basic "every user has a profile" page sprouted dedicated endpoints after A/B tests on inline editing (bio, avatar) and incidents with
usernamechanges anddisplayNameimpersonation. - Settings. Support got tired of "I can't unsubscribe from emails" tickets — email-prefs were carved out into a separate flow. Privacy and blocking-rules — under GDPR and extended blocking. Language and theme are separated because each has its own external flow (an i18n cache, theme sync across devices).
-
Me and my data. The
meendpoint and stats — the basic surface for the frontend. Engagement-stats was added after the creator-tools release. - GDPR / Compliance. Deletion with a 14-day window, restoration, and export of all data as JSON — the mandatory trio under GDPR/CCPA.
External (what neighboring modules need):
-
Identity. The basic "create / find / check user" operations. Some were introduced from day one (for Auth), some — after incidents:
bulk-get-usersafter the first slow feed with N+1,check-user-existsafter a DB-load analysis ("why pull an object when a boolean is enough"),display-info— a lightweight profile for rendering in comments. -
Graph. The social graph: follows, visibility, notifications.
can-view-contentappeared after the private-accounts release;check-blocked-by— after an incident in which Feed delivered content from a user who had blocked the viewer. -
Access. Permissions and statuses: blocks, verification, tiers (after monetization),
get-user-sincefor anti-fraud (a new account = less trust),permissionsafter the admin panel was expanded.
And what matters: in each of these cases, a new endpoint is a new folder next to the others, with no edits to the old ones. The graph grows vertically, not outward.
The graph has grown threefold, but its shape is the same. The same five boxes, the same arrows top to bottom. If three more releases come tomorrow and the count of use-cases reaches sixty — the picture won't change structurally: the same five boxes, just with the HTML labels inside getting cramped. No new edges, no new graph entities.
This is what's called "the architecture stayed in shape." Not because we didn't touch it — we added dozens of new files. It's because we didn't deform it: we didn't have to introduce new types of relationships, rewrite existing use-cases under new requirements, or untangle accumulated compromises. Every requirement landed as a new folder next to the others, and the old folders had no interest in it.





Top comments (0)