Practical guide

How to split a monolithic application into modules

A monolith does not have to be a problem. Split it where changes truly hurt, one safe step at a time.

25 minutes · Architecture

First, the short version

A module is not a microservice

A monolith is one deployable application. It can still have good internal boundaries. A module is a part of code that keeps one business whole together, such as orders or inventory.

The goal is not to create as many folders as possible or move to microservices immediately. The goal is to reduce coupling: a change in inventory should not break invoicing merely because they accidentally share internal details.

Get ready

What you need before the first move

First find out where the application hurts most. That is usually a better start than drawing modules from database tables.

  • One frequently changing area, such as orders, catalogue, or invoicing.
  • A list of places usually changed with it: controllers, services, entities, templates, and integration code.
  • At least basic automated tests around the chosen area. Without them, you will not know whether a move broke anything.
  • An agreement that a module has public entry points and other parts will not reach into its internal classes.

Steps 1 to 3

Split the monolith in small cuts

Move by feature, not by file type. The goal is a coherent unit that can do its own work.

1. Find the first bounded context

  1. Choose an area with its own language and rules. Orders, products, and invoices are often better boundaries than “controllers” and “entities”.
  2. Write what belongs to the area and what does not. An order can ask for payment, for example, but should not know a payment gateway’s internal tables.
  3. Start with one module. Five new modules without clear responsibility only move confusion into new folders.
mkdir -p src/Order
Official Symfony recommendations for code organisation

2. Create a small public module entry point

  1. Write down what the rest of the application may ask the module for: for example, PlaceOrder or CancelOrder. These are its public use cases.
  2. A controller or another module must not change module entities directly. It calls a public use case and receives a result.
  3. Keep data between modules small. Pass IDs, values, and results instead of an entire object graph through the application.
Official Symfony documentation for service wiring

3. Move one feature with its rules

  1. Choose one concrete use case. Move its application service, domain rules, and tests together.
  2. Keep the old call temporarily as a thin transition layer. Remove it only after callers have moved over.
  3. Put every dependency leaving the module behind an interface. The module then does not get tied to a specific database, email service, or external SDK.
php bin/phpunit --testsuite=Order
Official Symfony autowiring documentation

Step 4

Check that the module holds together

A good first module makes the next change easier. It does not need to be perfect or completely isolated from everything else.

  1. Run tests for the chosen area

    First check only the feature you moved. Then run the complete test suite.

    php bin/phpunit
  2. Search for direct access into the module

    Inspect imports from other application parts. They should not depend on a module’s internal classes unless that is an explicitly agreed exception.

    rg "App\\Order\\" src
  3. Make a small rule change

    For example, change the condition under which an order can be cancelled. You should mostly edit the module and its tests, not random files across the application.

If something goes wrong

Common problems

A new module exists, but everything still calls old services

Move one specific caller to the module’s public use case first. Then another. Without gradual redirection, the new module remains only a code copy.

Modules share database entities across their boundary

Stop passing around the entire entity. Give the other module an ID or a small data shape. If it needs more, consider its own query or a public application entry point.

The split stopped feature development

Make the cut smaller. The goal is not to clean an entire module in a week, but to safely move one valuable feature while normal changes continue.

Every module has a copy of the same rule

Before extracting a shared library, check whether it truly is the same rule. Similar code can have different business meaning; premature sharing creates more coupling.

Done

The monolith has its first clear boundary.

Add more modules only when there is a real reason. One understandable boundary is more valuable than ten folders with new names.

Request a call

I will call you on the next working day between 9:00 and 17:00.

You can also call me directly.

+420 605 181 728

Leave your phone number and send a callback request.

By sending, you agree to processing your data in order to handle your request.