📰 IT Security NachrichtenAbliteration: Startup entfernt KI-Sicherheitsfilter gezielt - BornCity(03.09.2026 um 19:27 Uhr)
📰 IT Security NachrichtenNVIDIA kauft Hugging Face - IT-Administrator(03.09.2026 um 21:05 Uhr)
📰 IT Security NachrichtenAbliteration: Startup entfernt KI-Sicherheitsfilter gezielt - BornCity(03.09.2026 um 19:27 Uhr)
📰 IT Security NachrichtenNVIDIA kauft Hugging Face - IT-Administrator(03.09.2026 um 21:05 Uhr)

26 🕛 kürzlich 18 Min Lesezeit CVE-RADAR
0

Feature Based Clean Architecture. Part 4: FBCA: Formalizing Responsibility Boundaries in a NestJS Module

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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.




For three parts we've watched how an "ordinary" NestJS project arrives at forwardRef and the rest of the wall. It's time to answer the question, "how not to do this." This is the moment to say "Clean Architecture" — and immediately offer a caveat. Anyone who has read about it for more than five minutes knows: there are so many contradictory interpretations grazing around this term that for two different people "Clean Architecture" means two different systems. It isn't a single approach, and it isn't the circles Bob Martin drew in the form of commandments. It's a family of ideas that converges on a single thesis: business logic is separated from infrastructure, and dependencies flow in only one direction. Everything else is implementation choices.






The basic idea



If you strip out all the "architectural magic," what's left is a few very simple rules:




  • business logic must not depend on frameworks

  • dependencies should go in one direction — inward, toward the domain

  • code should have boundaries, not be a dump

  • the database, HTTP, queues — these are just details, not the center of the system



Everything else is a consequence of breaking these rules.






Domain / Use Case / Infrastructure / Presentation



To stop building a system that turns into legacy half a year later, we finally need to introduce boundaries. Not "folders for the sake of order," but boundaries that can't be accidentally crossed — without breaking through TypeScript, NestJS DI, or the team's own discipline.



The simplest working model:





  • Domain — what is actually going on in the business


  • Use Case — which scenarios we implement


  • Infrastructure — how it's stored and how it works technically


  • Presentation — how all of this is approached from the outside



This isn't about a "pretty project structure," it's about control over the system: without boundaries, logic flows wherever, dependencies grow, and code turns into chaos. And this isn't a matter of taste — every architecture has a formal description through graph theory, where modules and services are vertices and dependencies are edges. On such a graph it's strictly visible why one system degrades while another stays manageable. Clean Architecture in this sense is a way of imposing constraints on that graph. We'll tackle the numbers in part 5.



We move from simple to complex — redoing the same project from the other side, starting with the original structure.






The old project structure






CODE
src/
├── main.ts
├── app.module.ts

├── modules/
│ ├── auth/
│ ├── users/
│ ├── tweets/
│ ├── feed/
│ ├── likes/
│ ├── comments/
│ ├── retweets/
│ ├── follows/
│ ├── notifications/
│ ├── search/
│ └── media/

├── common/
│ ├── guards/
│ ├── interceptors/
│ ├── filters/
│ ├── decorators/
│ └── utils/

├── database/
│ ├── typeorm/
│ └── migrations/

├── config/
│ └── configuration.ts






The structure itself isn't bad — splitting by features is a working approach. The trouble isn't on the top level, but in the fact that the internals of the modules and their interactions with each other have no rules that would keep the system in shape after half a year of work.






Previously the internal module was structured like this






CODE
src/modules/tweets/
├── tweets.module.ts
├── tweets.controller.ts
├── tweets.service.ts
├── dto/
│ └── create-tweet.dto.ts
├── entities/
│ └── tweet.entity.ts









How will we introduce Clean Architecture?



I propose we don't abandon the idea of splitting the system's modules by features or by the project's key domains. Classical Clean Architecture has its own problems, which are also hard to solve. We'll be doing what is essentially Feature Based Clean Architecture.






How the internal module will be structured now






CODE
src/modules/tweets/
├── domain/
│ └── tweet.ts

├── use-case/
│ └── create-tweet/
│ ├── create-tweet.handler.ts
│ └── create-tweet.module.ts

├── infrastructure/
│ └── repositories/
│ ├── tweet.entity.ts
│ ├── tweet.repository.ts
│ └── tweet.repository.module.ts

└── presentation/
├── tweets-presentation.controller.ts
├── tweets-presentation.service.ts
├── tweets-presentation.module.ts
└── dto/
└── create-tweet.dto.ts






The structure literally reads in layers.





  • Presentationpresentation/. The transport: controller, DTO, presentation-service. It knows only about HTTP and which use-case to call.


  • Use-caseuse-case/create-tweet/. The "create a tweet" scenario: orchestration, checks, all the business logic. That very place where, in feature-based, TweetsService would bloat.


  • Infrastructureinfrastructure/repositories/. Data access through a repository abstraction, plus the ORM entity that only the repository sees.


  • Domaindomain/tweet.ts. The model and its invariants, with no knowledge of where it's stored or how it got here.



The rule that holds all of this together — dependencies point inward: presentation → use-case → infrastructure (through the repository port) → domain. Domain knows about no one. Use-case knows nothing about HTTP. Infrastructure knows nothing about the scenario.






Let's also start with auth






CODE
@Controller("auth")
export class AuthPresentationController {
constructor(
private readonly authPresentationService: AuthPresentationService,
) {}

@Post("sign-up")
async signUp(@Body() dto: SignUpDto): Promise<SignUpResponse> {
return this.authPresentationService.signUp(dto.email, dto.password);
}

@Post("sign-in")
async signIn(@Body() dto: SignInDto): Promise<SignInResponse> {
return this.authPresentationService.signIn(dto.email, dto.password);
}
}






And here's the most interesting question, the one we kept putting off in feature-based until it was too late: how does Auth actually get the user? In the previous architecture, AuthService.signUp quietly burrowed straight into UsersService.create(...), which in turn called something else, and a couple of iterations later we had a forwardRef, a cycle, and the very case from part 3. Here, SignUpHandler lives inside Auth, and the user data lives inside Users. A boundary has to run between them — otherwise everything we've built here will simply move into the use-case, and a couple of sprints later the handler will start importing UserRepository directly. Before drawing that boundary — let's look at the Users module itself.




CODE
src/modules/users/
├── domain/
│ └── user.ts

├── use-case/
│ ├── create-user/
│ │ ├── create-user.handler.ts
│ │ └── create-user.module.ts
│ │
│ └── get-user-by-email/
│ ├── get-user-by-email.handler.ts
│ └── get-user-by-email.module.ts

├── infrastructure/
│ └── repositories/
│ ├── user.entity.ts
│ ├── user.repository.ts
│ └── user.repository.module.ts






How will Auth communicate with Users to avoid repeating past mistakes? We need a contract — and the most honest way to justify it comes through microservices. In the microservice world, the database per service pattern has been around for a long time: each service has its own database, and a neighbor doesn't reach into your table, no matter how much they'd like to. Out of this restriction, a useful side effect emerges on its own — Auth and Users start communicating only through an explicit, restricted contract, because there's no other way. The idea is so good it would be a shame to leave it only beyond the network boundary: it can be carried inside the monolith, without spinning up a zoo of real services. This contract is what's called a port.




A port is the module's interface,

describing what it exposes outward — and nothing else.




That is, not an implementation, not a repository, not a business scenario. It's a declaration — a list of operations and data available to neighboring modules, as if a network boundary stood behind the module. You can pick your own naming; what matters is the meaning. Next — how this port will fit inside Users.




CODE
src/modules/users/
├── domain/
│ └── user.ts

├── use-case/
│ ├── create-user/
│ │ ├── create-user.handler.ts
│ │ └── create-user.module.ts
│ │
│ └── get-user-by-email/
│ ├── get-user-by-email.handler.ts
│ └── get-user-by-email.module.ts

├── infrastructure/
│ └── repositories/
│ ├── user.entity.ts
│ ├── user.repository.ts
│ └── user.repository.module.ts

├── external/ # The port (contract) of the Users module
│ ├── users-external.module.ts
│ └── users-external.service.ts






An important detail: neither outside nor inside the module is the entity exposed — everywhere the code leaves the repository, what travels is user.ts. The handler that actually drives the business logic never holds a UserEntity in its hands: the repository itself goes to the database, maps the row into the entity, converts it into a domain object, and passes that on. Which means the use-case doesn't care which ORM lies underneath — TypeORM, Prisma, raw SQL — and which database is there. So if one day you want to move from TypeORM to Prisma, or from Postgres to Mongo, the migration hits the infrastructure layer: rewrite the entity, rewrite the body of the repository, fix the connection config. The business logic won't notice the swap. If the entity surfaced outward — whether through the port or straight into the use-case via findOne()save(), decorators, and the binding to the table schema would ride out with it; and any neighboring module would start mutating data around your port.




CODE
// user.ts
export type User = {
id: string;
email: string;
password: string;
createdAt: Date;
};

// user.entity.ts
@Entity("users")
export class UserEntity {
@PrimaryGeneratedColumn("uuid")
id: string;

@Column({ unique: true })
email: string;

@Column()
password: string;

@CreateDateColumn()
createdAt: Date;
}

// user.repository.ts
@Injectable()
export class UserRepository {
constructor(
@InjectRepository(UserEntity)
private readonly repository: Repository<UserEntity>,
) {}

async create(data: CreateUserData): Promise<Result<User, CreateErrorCode>> {
const insertUserResult = await fromAsyncThrowable(async () =>
this.repository.insert(data),
)();

if (insertUserResult.isErr()) {
if (isUniqueQueryError(insertUserResult.error)) {
return err("CREATE_USER_CONFLICT");
}

return err("CREATE_USER_DATABASE_ERROR");
}

const now = new Date();
return ok({
id: insertUserResult.value.identifiers[0].id,
email: data.email,
password: data.password,
createdAt: now,
});
}

async findByEmail(
email: string,
): Promise<Result<User | undefined, FindErrorCode>> {
const findUserResult = await fromAsyncThrowable(async () =>
this.repository.findOne({ where: { email } }),
)();

if (findUserResult.isErr()) {
return err("FIND_USER_DATABASE_ERROR");
}

return ok(findUserResult.value ?? undefined);
}
}






Since we're in the context of NestJS, we need to think about how use-cases and ports will inject the repository. The rule is simple: each repository lives in its own module and is plugged in one at a time, where it's needed. No UsersInfrastructureModule that exports everything at once. The reason is in the dependency graph. When get-user-by-email.module.ts explicitly writes imports: [UserRepositoryModule], at a glance it's clear what exactly this scenario needs. When a generic UsersInfrastructureModule sits there — nothing is clear: the handler can reach into UserRepository, into UserProfileRepository, into UserSettingsRepository, and all of that is invisible to the reviewer. And half a year later it'll turn out that get-user-by-email quietly pulls in three unnecessary tables, because "it's coming from the shared module anyway."



In essence, what we're doing here is taking the idea of Clean Architecture and lowering it to the level of the DI framework: the same boundaries between layers, only now they're expressed not through a directory, but through a Nest module. The rule "a handler knows only about its own dependencies" stops being a convention point and becomes mechanical. The architecture and the DI graph begin to coincide.




CODE
// user.repository.module.ts
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
providers: [UserRepository],
exports: [UserRepository],
})
export class UserRepositoryModule {}






Now we need to expose two methods, createUser and getUserByEmail, through the port so that auth can use them. And here it's worth noting that you can't simply hand a repository method out through the port directly. A repository is the data layer — it has no checks, no invariants, no orchestration; if the port calls it bypassing the use-case, there's simply nowhere to put any business logic around that call. Today, "get a user by email" is one query. Tomorrow — a query plus a block check, a cache, and tracking. All of that lives in the handler. If the port goes around it, the handler is excluded from the chain, and any new logic will have to land either in the repository (a layer violation) or in the port (the same). That's why we need a dedicated handler that will perform exactly this function.




CODE
// get-user-by-email.handler.ts
@Injectable()
export class GetUserByEmailHandler {
constructor(private readonly userRepository: UserRepository) {}

async run(
email: string,
): Promise<Result<User | undefined, GetUserByEmailHandlerErrorCode>> {
const findUserResult = await this.userRepository.findByEmail(email);

if (findUserResult.isErr()) {
return err("GET_USER_BY_EMAIL_DATABASE_ERROR");
}

return ok(findUserResult.value);
}
}






The same rule as for the repository: one scenario — its own module, its own export. Whoever needs GetUserByEmailHandler imports exactly that, not "all of Users's use-cases in bulk."




CODE
// get-user-by-email.module.ts
@Module({
imports: [UserRepositoryModule],
providers: [GetUserByEmailHandler],
exports: [GetUserByEmailHandler],
})
export class GetUserByEmailModule {}






In the end, UsersExternalService works as a thin facade: it holds the handlers inside and forwards calls into them, deciding nothing on its own. This is the very public contract for whose sake we were remembering database per service. Neighboring modules see neither UserRepository nor the internal handlers — they see only the set of operations Users has explicitly laid out in external. Everything else is conceptually behind a "network boundary" that doesn't actually exist, but that exists in the rules.




CODE
// users-external.service.ts
@Injectable()
export class UsersExternalService {
constructor(
private readonly createUserHandler: CreateUserHandler,
private readonly getUserByEmailHandler: GetUserByEmailHandler,
) {}

async getUserByEmail(
email: string,
): Promise<Result<User | undefined, GetUserByEmailHandlerErrorCode>> {
return this.getUserByEmailHandler.run(email);
}

async createUser(
data: CreateUserData,
): Promise<Result<User, CreateUserHandlerErrorCode>> {
return this.createUserHandler.run(data);
}
}






Let's return to the Auth module. The new architecture doesn't let you stuff a scenario just anywhere — but it also won't tell you if you stick it in the wrong place. Before showing the right place, let's look at a typical mistake: plugging UsersExternalService directly into AuthPresentationService, right at the transport itself.



One important nuance. Nest won't complain in this case: DI will calmly resolve the dependency, because UsersExternalService is exported and AuthPresentationService is allowed to import it. The "who can call whom" restrictions are not the DI graph — they're layer rules; for a machine to catch them, you need a separate linter like



Why does this approach help the architecture hold its shape longer? Not because Nest watches over this — it doesn't watch, it's just a DI framework, and it knows about layers no more than tsc does. The protection rests on three things, none of which come out of the box.



Convention. Every module exposes outward only *External*Service; handlers, repositories, the domain — are exported nowhere. This can be broken; Nest will calmly hand out any provider you decide to export. But the shortcut "let me just open GetUserByEmailHandler to Auth" leaves a trace: users-external.module.ts changes, an export gets added, it's imported into the Auth handler. Each of these files lands in the diff and at review.



Linter. eslint-plugin-boundaries or dependency-cruiser lets you describe "auth/ imports only from users/external/*" — and catch violations in the IDE before commit. This is the very layer where the architectural rule becomes checkable at build time. Optional, but it's exactly what turns discipline from "we agreed" into "won't pass."



Review. When there's no linter, what's left is eyes. Without a linter, the FBCA structure helps not with "don't degrade," but with "degrade more visibly": every shortcut now leaves a diff that looks suspicious. It lowers the odds, but not to zero.



In sum: a new feature lands cleanly into a new module more easily than sloppily — not because sloppily isn't allowed, but because sloppy is now visible. Half a year later, it's exactly this asymmetry that determines how quickly the team adds features.



For comparison, let's look at what the feature-based dependency graph would look like at this stage of development.





It looks much simpler, which is why it's hard to grasp the real meaning of Clean Architecture right away. The deceptiveness of the FB graph is in one thing: it's drawn by new projects. Legacy in which AuthService has grown ten dependencies and a forwardRef doesn't publish its own graphs — it's already too well-known to the team for anyone to bother drawing it.



So when a newcomer sees the comparison "here's FB — three classes and an arrow," "here's FBCA — ten classes and clusters," they're looking at the phase in which FB really does win: the first month of the project. This is survivorship bias in pure form: static comparisons almost always show the moment when nothing has had time to break yet.



Understanding of Clean Architecture comes not right away — when it seems cumbersome — but a year later, when it turns out it hasn't fallen apart. And it's exactly this difference that's hard to feel in advance: people see today's cost, and they find it hard to believe that the investment will pay off.



In the next part, I'll show visually why feature-based started to degrade, while feature-based-clean will keep going for a very long time.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 36%
🟡 In Evaluierung 20%
🟢 Keine Auswirkung 17%
Spannende Innovation 27%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
NVIDIA kauft Hugging Face - IT-Administrator
1 Quelle
<b>Cyber</b>-Kriminelle verschlüsseln <b>IT</b>-Systeme der Stadtwerke Landsberg.
1 Quelle
Palo Alto: Cybersicherheitsinfrastruktur nicht auf KI vorbereitet - it-daily.net
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Feature Based Clean Architecture. Part 4: FBCA: Formalizing Responsibility Boundaries in a NestJS Module

Thematisch verwandte Begriffe: Feature, Based, Clean, Architecture · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...