You Don't Need Microservices (Yet), You Need to Learn to Divide Your Code.

By Juan Villarroel Published on 5 min read

You Don't Need Microservices (Yet), You Need to Learn to Divide Your Code.
Architecture Vertical Slices Monolith Clean Code

Quick answer: Stop organizing your code by "file folders" and start thinking in business domains. Discover how decoupling in a monolith is the key to scaling quickly and facilitating a future migration to microservices.

The Chaos of Horizontal Layers: "Shotgun Surgery"

Most of us start the same way: a Controllers folder, another for Services, and one for Models. In textbooks, it seems like the perfect order, but in the real world, as the application grows, this design becomes a trap.

I bet you've been there: you're asked to change a single field in an Invoice. To do it, you have to open 5 different tabs in your editor: the invoice controller, the invoice service, the repository, the DTO, and the validation logic. All of them are in separate folders, buried among 50 other files that have nothing to do with what you're currently working on.

This is called Shotgun Surgery: a single change forces you to "spray" modifications all over the project. Coupling is high, and the cognitive load—the mental effort required to track where everything is—becomes exhausting.

Vertical Slices: Think in Domains, Not Files

The proposal is simple yet transformative: Divide your application by what it does, not by what it is. Instead of grouping all the world's controllers in one place, we group everything related to a business context or "Slice." Imagine each feature is a cake: instead of just eating the top layer (the frosting), you cut a vertical slice that includes every layer (UI, Logic, Data).

Suggested Structure:

  • Auth: (Controller, Service, JWT logic, Repository)
  • Users: (Controller, Profile Service, Validations)
  • Invoices: (Controller, Tax Logic, PDF Generator, Repo)

By organizing your monolith this way, you are creating decoupled contexts. If tomorrow the Invoices module grows so much that it needs its own database, migrating to a microservice will be almost a "copy-paste" job.

A real example: the Invoices slice

So this doesn't stay purely theoretical, here's what a full request looks like inside a single slice: it enters through the controller, gets validated by a DTO, runs through the business logic in the service, and ends up persisted by the repository. The whole flow lives together, in the same invoices/ folder:

// invoices/dto/create-invoice.dto.ts
export class CreateInvoiceDto {
  @IsUUID()
  customerId: string;

  @IsNumber()
  @Min(0)
  amount: number;

  @IsISO8601()
  dueDate: string;
}

// invoices/invoices.controller.ts
@Controller("invoices")
export class InvoicesController {
  constructor(private readonly invoicesService: InvoicesService) {}

  @Post()
  create(@Body() dto: CreateInvoiceDto) {
    return this.invoicesService.create(dto);
  }
}

// invoices/invoices.service.ts
@Injectable()
export class InvoicesService {
  constructor(private readonly invoicesRepository: InvoicesRepository) {}

  async create(dto: CreateInvoiceDto): Promise<Invoice> {
    const tax = dto.amount * TAX_RATE;
    return this.invoicesRepository.save({ ...dto, tax });
  }
}

// invoices/invoices.repository.ts
@Injectable()
export class InvoicesRepository {
  constructor(private readonly db: DatabaseService) {}

  save(invoice: Omit<Invoice, "id">): Promise<Invoice> {
    return this.db.invoices.create({ data: invoice });
  }
}

Four files, one single purpose. Nobody needs to understand how Auth or Users work to read or change this: the entire business context for "invoices" is a Cmd+P away.


Why is this approach a game-changer?

Dividing by vertical slices isn't just an aesthetic preference; it’s a strategic decision with tangible benefits:

  1. Ultra-fast Onboarding: When a new developer joins the team and needs to work on "Payments," they don't need to learn the entire system architecture. They just open the Payments folder, and all the context is right there.
  2. Ease of Testing: Unit and integration tests live right next to the code they verify. It’s much easier to mock dependencies when domain boundaries are crystal clear.
  3. Time-to-Market Speed: By reducing the friction of navigating through infinite folders, the team can develop features end-to-end much more efficiently.
  4. Fewer Git Conflicts: Developers are less likely to touch the same files if one is working on Auth and another is working on Invoices. Everyone works in their own "slice."

Pros and Cons: Keeping it Real

Like any architectural decision, this isn't a silver bullet. There are trade-offs you should be aware of.

The Upside (Pros)

  • Low Coupling: A change in the tax logic inside Invoices has zero chance of breaking the login process in Auth.
  • Intuitive Navigation: The project explains itself through its business folders.
  • Evolutionary Scalability: It allows you to grow in an organized way. If the monolith becomes a giant, it's already "pre-segmented" to transition into microservices without the pain.

When is it NOT beneficial? (Cons)

  • Basic CRUD Applications: If your app only performs 4 basic operations on 2 tables, this structure is Overengineering. You're adding unnecessary folders and files for something that won't need to scale.
  • Intentional Code Duplication: Sometimes, to keep slices independent, you might end up duplicating a UserDTO class in two different modules. For some purist developers, this is hard to swallow (even though it's often better than a tight dependency).
  • Team Discipline: It requires everyone to understand where one domain ends and another begins. Without a shared vision, the team might end up creating "Monster Slices" that mix everything up again.

Conclusion

Organizing your code by domains from Day 1 isn't "premature scaling." It's technical hygiene and respect for your future self. A well-modularized monolith is much more valuable, cheaper to maintain, and easier to evolve than a poorly managed microservices ecosystem.

Stop thinking in files; start thinking in business capabilities. Your code (and your mental health) will thank you.

Taking it into a real project

I would not reorganize the entire repository in one branch. Start with a capability that changes frequently and has recognizable boundaries, such as invoicing. First, capture critical behavior in a test. Then move its controller, operation, validation, and persistence into one module without changing the public API.

A safe migration order is:

  1. Choose one use case and list its real dependencies.
  2. Create the slice and move only that flow.
  3. Keep a temporary adapter to shared legacy code.
  4. Run tests and observe errors or latency in production.
  5. Remove the adapter after no flow depends on it.

Track imports across modules during the migration. If Invoices needs user data, expose a focused operation from Users instead of importing its repository. The direction of that dependency says more about the architecture than folder names do.

I would also tolerate small, temporary duplication. Extracting a shared helper too early can recreate the coupling you were trying to remove. When two slices repeatedly change together, you finally have evidence for a common abstraction.

Measure the result after each migration. A useful slice should reduce the number of unrelated files touched by a change, make ownership clearer, and shorten the time needed to trace one request. If the new structure only adds folders without improving those outcomes, revise the boundary before migrating the next capability.

If you work with TypeScript, my experience with NestJS helps evaluate how its modules and providers can represent these boundaries without turning every operation into ceremony.

The rule remains the same: create a boundary because it protects a frequent change, not because a diagram looks more professional. That discipline connects directly with making imperfect but correct decisions.

Related Posts