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.
Now let's see what this "decomposition into services" actually costs on a horizon a little longer than one sprint. Our product already has a FollowsService — it lives tidily in its own module, drops "followerId → targetUserId" relations into its table, and knows nothing it shouldn't. In parallel — UsersModule, with the pieces split out into separate services: UserPrivacyService and UserSettingsService. Each is responsible for its own facet of the user. Single responsibility, the implementation is clean, review is a celebration.
Then complaints start coming in through support: strangers send all kinds of nastiness in DMs, users want to hide their profiles from random eyes. The request is clear, human — the PM picks it up: we'll do private accounts. You can't follow a private profile directly — you send a follow request, the owner either approves it or not. The logic fits on half a page in Notion. The idea is trivial, a day's work, maybe two.
Case: private accounts and follows
The ticket lands in the backend. What needs to change? Before FollowsService creates a record in follows, it has to go and find out whether the account being followed is private. If yes — we drop the request into follow_requests and wait for the owner's approval. If no — we continue as usual. Knowledge about privacy lives in UsersModule (where else should it live?), which means FollowsService starts depending on UserPrivacyService. A dependency like that passes review in thirty seconds.
@Injectable()
export class FollowsService {
constructor(private readonly userPrivacyService: UserPrivacyService) {}
async follow(
followerId: string,
targetUserId: string,
): Promise<FollowResponse> {
const isPrivateResult =
await this.userPrivacyService.isPrivate(targetUserId);
if (isPrivateResult.isErr()) {
throw new InternalServerErrorException(isPrivateResult.error);
}
if (isPrivateResult.value) {
return this.createFollowRequest(followerId, targetUserId);
}
return this.createFollow(followerId, targetUserId);
}
private async createFollowRequest(
followerId: string,
targetUserId: string,
): Promise<FollowResponse> {
// create the follow request
}
private async createFollow(
followerId: string,
targetUserId: string,
): Promise<FollowResponse> {
// create the follow
}
}
At this stage, everything really is in order. FollowsService asked UsersModule: "private or not?" — and moved on. One dependency arrow, going one way, drawn on the architect's diagram without loops. If features ended here — we'd have neither this article nor the problem. But features, as a rule, don't end.
A couple of sprints later, a second scenario appears in the product: a viewer visits someone else's profile. And here a whole batch of questions surfaces that "private or not" no longer covers. Can the viewer see the profile? Can they see the tweets? Can they see the likes, the stories, someone else's follows? The logic grows: each type of content gets its own rules, private accounts get their own, blocked users get separate ones. The old UserPrivacyService can no longer handle this (it can only answer the binary question "private or not"), and the team sets up a new service alongside it — UserAccessService. By every textbook — single responsibility, separation of concerns, everything as it should be.
And it's at exactly this point that UserAccessService runs into an uncomfortable question. To answer "can the viewer see the owner's tweets," it has to know whether the viewer follows the owner. And that information lives in FollowsService. Which means UserAccessService starts depending on FollowsService.
@Injectable()
export class UserAccessService {
constructor(
private readonly followsService: FollowsService,
private readonly userPrivacyService: UserPrivacyService,
) {}
async canViewProfile(
viewerId: string,
ownerId: string,
): Promise<Result<boolean, UserAccessErrorCode>> {
const isPrivateResult = await this.userPrivacyService.isPrivate(ownerId);
if (isPrivateResult.isErr()) {
return err(isPrivateResult.error);
}
if (!isPrivateResult.value) {
return ok(true);
}
const isFollowingResult = await this.followsService.isFollowing(
viewerId,
ownerId,
);
if (isFollowingResult.isErr()) {
return err(isFollowingResult.error);
}
return ok(isFollowingResult.value);
}
async canViewTweets(
viewerId: string,
ownerId: string,
): Promise<Result<boolean, UserAccessErrorCode>> {
const isPrivateResult = await this.userPrivacyService.isPrivate(ownerId);
if (isPrivateResult.isErr()) {
return err(isPrivateResult.error);
}
if (!isPrivateResult.value) {
return ok(true);
}
const isFollowingResult = await this.followsService.isFollowing(
viewerId,
ownerId,
);
if (isFollowingResult.isErr()) {
return err(isFollowingResult.error);
}
return ok(isFollowingResult.value);
}
}
We have our first cycle. At first glance — an innocuous problem: at the service level, it's still hidden behind good naming.
UserAccessService:
→ FollowsService
→ UserPrivacyService
FollowsService:
→ UserPrivacyService
All arrows point forward, none of them backward — formally pretty. But at the module level, the picture is already mirrored:
FollowsModule ⇄ UsersModule
FollowsModule imports UsersModule for UserPrivacyService. UsersModule imports FollowsModule for FollowsService. Each import on its own is completely justified — any reviewer can let it through and approve. But together they form a trap that won't snap shut today, and not tomorrow — but in two or three sprints, on a random Thursday, on someone's PR that has nothing to do with these imports themselves.
And now the same thing in code
If you unfold all of this into a flat set of @Module decorators, the picture looks deceptively simple. FollowsModule declares "I need UsersModule," UsersModule declares "I need FollowsModule":
@Module({
imports: [UsersModule],
providers: [FollowsService],
exports: [FollowsService],
})
export class FollowsModule {}
@Module({
imports: [FollowsModule],
providers: [UserPrivacyService, UserAccessService],
exports: [UserPrivacyService, UserAccessService],
})
export class UsersModule {}
Four imports, two exports, no forwardRef, no tricks. Not a single line looks suspicious — on any of them, in any team, you'll get an approve in ten seconds. And it's exactly this pair of imports that's enough for NestJS to throw that very Nest can't resolve dependencies.
Nest provides the cure itself, right in the documentation — forwardRef. You wrap the import in an arrow function, and DI resolves the dependency lazily, at use time, not at build time. Sounds like permission from above: the framework doesn't just allow it — it recommends it. The team reads, nods, patches both decorators:
@Module({
imports: [forwardRef(() => UsersModule)],
providers: [FollowsService],
exports: [FollowsService],
})
export class FollowsModule {}
@Module({
imports: [forwardRef(() => FollowsModule)],
providers: [UserPrivacyService, UserAccessService],
exports: [UserPrivacyService, UserAccessService],
})
export class UsersModule {}
And everything seems to work again. The build compiles, tests run, the logs are quiet. At review, the two words forwardRef will pass as easily as the imports themselves — it's a documented API, everything is legitimate. The PR gets merged — and for half a year the cycle is collectively forgotten.
And now — the moment forwardRef starts taking revenge
Half a year later, no one remembers the Users ↔ Follows cycle anymore. Life goes on, features ship, CI is green, new developers see forwardRef in imports and accept it as part of the project's style — if it's done everywhere, that's how it's supposed to be.
And then the team sits down to a new feature — a feed with comments. We already have CommentsModule and ModerationModule. Guess what relationship they're in. CommentsService calls into ModerationService to ask "can this comment be posted?" (anti-spam, ban, rate-limit). ModerationService calls into CommentsService for the user's last N comments to compute the spam score. A cycle? A cycle. forwardRef? forwardRef.
@Module({
imports: [forwardRef(() => ModerationModule)],
providers: [CommentsService],
exports: [CommentsService],
})
export class CommentsModule {}
@Module({
imports: [forwardRef(() => CommentsModule)],
providers: [ModerationService],
exports: [ModerationService],
})
export class ModerationModule {}
Now FeedModule appears. The feed is built from posts, under each post — comments, and comments from banned users have to be hidden. The straightforward option is to import both modules:
@Module({
imports: [CommentsModule, ModerationModule],
providers: [FeedService],
})
export class FeedModule {}
And you could stop there. But then a completely healthy architectural instinct kicks in:
"Hold on, why should
Feedeven know aboutModeration? Moderation is an implementation detail ofComments. If tomorrow we replace it with an ML model,Feedshouldn't have to know about it. Let's makeCommentsModulea facade and re-exportModerationServicethrough it."
This is the right reasoning. By every textbook — facades, encapsulation, minimum knowledge at the consumer. Any reviewer who knows their stuff will approve a PR like this.
@Module({
imports: [forwardRef(() => ModerationModule)],
providers: [CommentsService],
exports: [CommentsService, ModerationService], // facade-ing
})
export class CommentsModule {}
And here Nest tells us:
Nest can't resolve dependencies of the FeedService (?).
Please make sure that the argument ModerationService at index [0] is available in the FeedModule context.
No forwardRef will help here. Because forwardRef is not a module, it's a proxy-placeholder, and it can't be carried transitively through exports. At the moment CommentsModule is being assembled, ModerationService for it still "doesn't exist" in any full sense.
And so begins the iteration through options, none of which look appealing. You can import ModerationModule into Feed directly — but that's a rejection of the facade, and knowledge of moderation spreads out to everyone working with comments. You can drop the re-export and leave two separate imports — essentially the same thing, plus the feeling that the architecture is "leaking." You can break the cycle for real — rewrite CommentsModule and ModerationModule, dig into code that was touched half a year ago and that no one really remembers; a senior at standup promises "let's do it next sprint," and that sprint, naturally, never comes. You can, finally, pour more forwardRefs on top — sometimes it helps, sometimes it doesn't, always fragile, and the logs start showing pretty TypeError: Cannot read properties of undefined.
And this is the real cost of forwardRef: it doesn't solve the problem, it freezes it. It doesn't formally forbid normal patterns — but as soon as you try to apply them, it forces you to invent workarounds: the facade can't be done via re-export — let's do it through a separate aggregator service; then the aggregator itself falls into a cycle — let's call it through ModuleRef.get() at runtime; then in tests half the graph doesn't come up — let's mock by hand. And these dances around an old cycle eat up the lion's share of time on every next feature. Development turns into attempts to make this thing at least compile and start up, not into moving the product forward — even if the author of that cycle has long since quit, and all that's left in the code is their comment // TODO: untangle.
And now imagine that it isn't one cycle written like this, but 60% of the codebase. Refactoring on the inside is no longer possible on a reasonable timeline — any change in one module drags five neighboring ones along through forwardRefs, the "two-day" estimate turns into "two sprints," the result is unpredictable in advance. And nobody even wants to go into code that was touched eight years ago and that no one remembers anymore — fixing the old thing is psychologically more expensive than writing a new one next to it. And "next to it" in a broken monolith is already a separate service. And right here the team finds an "adequate" way out: chop the functionality into microservices. Not because that's domain-correct, not because the load demands it, but because the monolith physically doesn't compile, and no one inside is ready to fix it. Next — design the transport between services (gRPC? REST? Kafka? RabbitMQ?), separate synchronous and asynchronous interaction, set up load balancing and service discovery, split CI/CD into separate pipelines, write contract tests, smear everything with retries, circuit breakers, and distributed tracing, occupy DevOps for a quarter, retrain the team on network errors. And all of this — to hide comments from banned users. The perfect ratio of "infrastructure investment" to "product benefit": $10k a month in operating costs for one checkbox on a feature checklist.
Let's even calculate the cost difference for a small team. You have a product, your team is 2 backend engineers and 1 frontend engineer. Take an averaged fully-loaded cost for one mid-level backend engineer — $5k/month with all taxes, insurance, and equipment. Two backend engineers for a year = $120k.
In a healthy project, "fighting the architecture" — debugging the build, fixing tests, working around things — takes 5–10% of the time. In a project that's been sitting under forwardRefs for half a year to a year — a steady 30–50%. On the delta between these two states, $30–60k a year is literally burned on flat ground, even if not a single feature has been written in that period.
But these are just the direct costs. Much scarier — opportunity cost. In a healthy codebase your team ships a feature a week. With accumulated cycles the same team ships the same feature in three to four weeks — not because the developers got worse, but because 70% of the sprint goes to "why doesn't it compile / why don't the tests run / how do we get around the cycle." Over a year, that's 9 months of undelivered features. In a market where, over the same period, a competitor shipped a partner program, recommendations, and a paid subscription, you simply stop being considered.
And here's the final chord. That very first forwardRef, which half a year ago saved the team at most 4 hours, in fact gets paid for with $30–60k a year of direct costs and nine months of lost market. ROI of that decision: –35,000% in the first year, –180,000% by the fifth, –360,000% by the tenth. But no one counts it that way, because the bill doesn't come at once — it comes as a smeared payment across every future sprint — and there's no one really to blame: the author quit, the team turned over, and the forwardRef stands as it stood.
And now imagine if this is big tech or enterprise. The team isn't 2 backend engineers, but 50–200 people on a single product. The fully-loaded cost of one senior is already $30–40k/month with all benefits and L&D. One product — $20–30M a year just on development. The share of time spent "fighting the architecture" is the same — 30–50%. On the delta, that's $6–15M a year flat down the drain, without a single feature written.
On top of that, a separate platform team of 10–15 seniors emerges, whose only job is to help product teams work around their own architecture. Another $4–6M a year. Any attempt at a large migration is an 18–24 month project with a $15–30M budget, during which no features ship at all. In that time, a competitor ships three new product lines.
Then second-order effects kick in. The seniors who understand that fixing it is no longer possible leave within a year or two — their career here boils down to an endless fight with their own legacy. In their place come people who need half a year just to figure out where they can touch and where they can't. Inside the product, a "framework war" blooms: teams build their own local wrappers just to avoid touching the central modules. Any M&A deal slips on its timeline by half — integrating the acquired company into this tangle is physically impossible. Technical debt turns into a corporate risk at the board-of-directors level — and at the same time it still looks like "an innocuous forwardRef that someone put in eight years ago."
To summarize briefly: everything we've gone through in this part — from cycles and forwardRefs to millions per year and framework wars — is symptoms of the same problem. Modules have no internal structure. Services call services, methods call methods, and any attempt to "split things up properly" runs into the fact that there's nothing to split — inside the module everything is mixed equally. No one wrote bad code. It's just that no one wrote any good code either. In the next part, we'll start from the other side: what has to be inside a module to make a cycle like this physically impossible to build.
So what do we actually do? This example was meant to convey how poorly thought-out architecture turned the project's code into Schrödinger's cat. You and I, step by step, built a system that brought losses to the business, made time-to-market for features many times longer, turned developers from people who develop into people who stabilize a system held together with duct tape. Even though everything started off well.
Top comments (0)