Decoupling a monolith is not a rare problem. It has cropped up in most of the companies I’ve worked at. This happens because, at the early stages of any startup, there is so-called decision debt being accumulated. As a result, the chosen architecture is optimal for rapid development and experimentation, but not for a mature product environment.
Considering that this problem appears often, there are plenty of books and articles on how to approach it, but I feel like there are not enough practical examples. Therefore, I will focus on the technical details of how it looks, using a PHP application powered by Symfony as an example. Some of these insights and approaches have been utilized when developing our Lokalise TMS.
Before we start, what do we mean by “decoupling a monolith”? What is our end goal? To establish this, let’s consider what the business goals that drive engineering to start tackling this problem might be, including the need to support localized time for global user experiences. For instance:
Slow development speed. This happens due to highly coupled code, which includes:
• Parts of the code being too dependent, which leads to a higher chance of code conflicts and higher difficulty in establishing a proper CI/CD process.
• Technological shifts (e.g., upgrading or migrating to another framework) are nearly impossible due to high time and effort costs.
Monolith being a SPOF, which makes it a business risk.
Horizontal scaling of the application is more challenging.
Considering the above, we can state that our end goal might be tomigrate to a better application architecture to improve development speed, as well as the scalability potential of the application, and to lower infrastructural risks.
Now that we have the problem and the goal established, let’s figure out the high-level plan.
Example application
I will be using a food delivery application as an example. For the sake of simplicity, it will not contain any real logic but will include several cross-service and database calls. To quickly grasp the idea of the application, take a look at these diagrams:
Now, let’s focus on what exactly we call “coupled code” in terms of monolith architecture. First, even though domains are separated into so-called service classes in our code, they communicate with each other through direct method calls. Moreover, even if we change the way services communicate with each other, we still won’t be able to extract the code to a separate service because it is using classes from other domains directly (through the use keyword). Finally, all service classes have access to all data in the database, meaning there are no internationalization boundaries between domains in terms of data.
In real-world applications, even if we conceptually understand the required changes, there is a lot of code and effort required to separate them, especially when considering translation management system for multiple languages. So, we have to make the process iterative and predictable. For that purpose, we will introduce preliminary steps. One of them is turning our monolith into a modular monolith.
So, let’s introduce the definition of a module. We will call a part of our code a module when it follows two rules:
Code separation. Modules do not use classes of other modules directly. When one module needs to call another module, a service client is used.
Data separation. Each module uses its own database.
Let’s take a bit of a deep dive:
Modules do not use classes of other modules directly. To enforce this rule, we will be using a library called Deptrac. The way it works is pretty simple: you need to create a configuration file (deptrac.yaml by default), define modules, and add the library executable to your CI/CD pipeline. It can be GitHub Actions or anything similar. The important part is that it should be required (meaning, it should be not possible to merge a PR with this action failed).
When one module needs to call another module, a service client is used. The idea of this is simple: switching straight from monolith to services is usually a big leap, so we are preparing our code by using sub requests instead of real HTTP requests. The service client is just a helper class that forms a sub request, sends it to the corresponding module, and returns a response. We will look into the implementation of it soon.
Each module uses its own database. Depending on your application, you might opt for different database patterns, and “database per service” is just one of them. We will use it in our example because it’s one of the most common ones.
Considering the above, let’s observe the stages of a product during migration to a better architecture:
Big ball of mud
Modular monolith
Service oriented
Event driven
Communication method
Direct method calls
Direct method calls of module's service API
HTTP requests
Async messages
Characteristics
High coupling, low cohesion
Low coupling, high cohesion enforced by convention
Low coupling, high cohesion enforced by API contract
Coupling further decreased by relying on messages without defined single recipient instead of direct API calls
Data storage
Monolith database
Database per module
Database per service
Database per serivce
Product architecture improvement path
At the last stage, we won’t implement a full-fledged implementation of the event-driven architecture with things like event streams, bounded context models, the outbox pattern, and so on (hence the asterisk). However, we will change interservice communication to async messages because this is a typical change for applications that undergo this kind of architecture evolution and therefore worth showing in the example.
Implementation
Before we start, I cannot understate the importance of test coverage. We are not going to focus on this part in our example application, but in real-world applications, the first step before any architectural changes should be creating a layer of tests that either work on near-HTTP level (e.g., application tests in Symfony) or real E2E tests.
From a big ball of mud to a modular monolith
Grouping files
Once we have split the databases, we should then move the files to the appropriate directories but not untangle the dependencies yet. Let’s look at the current file structure:
Tip: If your IDE supports PSR namespaces, you can leverage it to move classes between namespaces, and it should fix references. For example, PhpStorm supports it out of the box if you synchronize your IDE settings with Composer.
Also, we have to slightly adjust the Symfony configs to support our new structure:
Tip: In real-world applications, since this step is technically just moving files, it can be split to multiple small PRs that are convenient for release so that the effort is iterative and predictable.
Splitting the databases
Now we need to figure out natural boundaries between the parts of our application. They are pretty obvious in our example application (Customer, Restaurant, and Courier domains). Still, in real-world scenarios, we can use either common sense or a domain-driven design (as a more advanced approach). One thing to keep in mind is not going with services that are too small, because this comes with a maintenance cost. As stated in Google’s article: “We recommend that you create larger services instead of smaller services until you thoroughly understand the domain.”
Once the boundaries are decided, the next step is to split our single database into separate ones, one per module. In the case of our example application, how we’ll split the databases is quite obvious:
customer database, which will contain the order table.
restaurant database, which will contain the restaurant table.
courier database, which will contain the delivery table.
In real-world applications, it is often not that straightforward and will require more effort to decide the ownership of the tables between modules. However, regardless of the scale of the application, the technical part remains the same — the trick is to perform a so-called “hot migration”, where we inject another database connection into our code and write into both databases while reading from the old one. In parallel, we need to run a script to migrate the rest of the data from the old table to the new one. In this article, we won’t focus on the implementation details of this part, as it’s worth an article of its own, but the typical algorithm is:
Find all usages of the table under migration within the code.
For reading, leave the old connection.
For writing, send queries to both databases.
Meanwhile, implement and run a script that will migrate the rest of the data from the old table to the new one.
After the data migration is done, release a PR that will use only the new connection for everything, clean up the code, and delete the old table.
Some other things to consider:
Joins. Since we are moving out certain tables to separate databases, joins between those tables will no longer be possible. Such places will have to be rewritten to separate queries (one per table).
Foreign keys. It will not be possible for an RDBMS to enforce foreign keys since the tables are in separate databases. If your application relies on such logic, it will have to be moved to the application code. It’s worth noting that it is often common to opt out of foreign keys in high load projects because it has its problems.
Transactions. This is probably the trickiest part because after the tables are split across multiple databases, an RDBMS will no longer be able to span transactions across such tables. Therefore, this process should be rethought and, depending on the business logic, such transactions will have to be either removed or rewritten within the application code using something like the sagas pattern.
Leaving the hot migration technical details aside, here is how the separated databases are going to look in our example application.
First, we need to introduce separate entity managers and connections:
Lastly, we need to resolve relations between entities, as they are now stored in separate databases and managed by different entity managers. The simplest way to resolve entity relations is to start using database columns directly instead of entities, for instance:
# src/Courier/Entity/Delivery.php namespace App\Courier\Entity;-use App\Customer\Entity\Order; use App\Courier\Repository\DeliveryRepository; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; #[ORM\Entity(repositoryClass: DeliveryRepository::class)] class Delivery { public const STATUS_NEW = 'new';@@ -28,9 +28,9 @@ #[Groups(['api'])] private ?string $status = null;- #[ORM\OneToOne(cascade: ['persist', 'remove'])]- #[ORM\JoinColumn(nullable: false)]- private ?Order $RelatedOrder = null;+ #[ORM\Column(name: 'related_order_id')]+ private ?int $relatedOrderId = null; public function getId(): ?int {@@ -49,14 +49,14 @@ return $this; }- public function getRelatedOrder(): ?Order+ public function getRelatedOrderId(): ?int {- return $this->RelatedOrder;+ return $this->relatedOrderId; }- public function setRelatedOrder(Order $RelatedOrder): static+ public function setRelatedOrderId(int $relatedOrderId): static {- $this->RelatedOrder = $RelatedOrder;+ $this->relatedOrderId = $relatedOrderId; return $this; }
As you can see, database separation already pushes our code in the modular direction. It will help us during the next steps.
You can see the full code example following this step here.
Enforcing boundaries between modules
Once the files are moved and databases are split, we can start enforcing boundaries between modules. As mentioned above, we will be using Deptrac to enforce these boundaries. Deptrac works using layers (modules) defined in deptrac.yaml and the vendor/bin/deptrac executable to see dependencies between these modules. This is useful in two scenarios:
When files are just moved to corresponding directories, we can define modules in the deptrac.yaml file, run the executable to see the full list of dependencies, and plan the work.
When dependencies are completely resolved, we can commit the deptrac.yaml file and include it in our CI/CD pipeline to ensure no new dependencies are introduced.
Time to see how it looks in practice. First, let’s define our modules in deptrac.yaml without committing the config file yet:
After running vendor/bin/deptrac, we will get a report explaining the dependencies between our modules.
Deptrac report with violations
We will be resolving these issues by introducing service clients and DTOs. It will be something like an SDK for our services that we will put in our Common directory, so all our modules will have access to it. The point of this is to have a layer where we can decide how exactly the inter-module communication takes place; therefore we can switch between direct function calls and, for example HTTP calls, much easier later. After the modules are split, the Common module can migrate to a separate repository and can be used as a composer library, to enable integration into future services.
To implement service clients, we will utilize a Symfony feature called sub requests, which will allow us to dispatch request objects to existing routes internally, without real over-the-network requests. In Node.js, there is a similar approach using mcollina/fastify-undici-dispatcher.
There is one caveat worth noting — sub requests have a slight overhead compared to direct method calls; thus, if used excessively, they can cause a slowdown. However, it has to be thousands of times in a loop to become at least noticeable. Plus, if you remember that those calls will become real HTTP requests later on, where the overhead will be much bigger, sometimes it’s even useful to notice such cases beforehand and potentially change the code so that there is no need to call the API in a loop.
Now, let’s first introduce the base class for all service clients:
# src/Common/Client/AbstractSymfonyControllerResolvingClient.php<?phpdeclare(strict_types=1);namespace App\Common\Client;use App\Common\Exception\BadPayloadException;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpKernel\HttpKernelInterface;use Symfony\Component\Serializer\Encoder\JsonEncoder;use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;use Symfony\Component\Serializer\Serializer;abstract class AbstractSymfonyControllerResolvingClient{ public const IS_INTERNAL_REQUEST_ATTRIBUTE_KEY = 'is-internal-request'; protected readonly Serializer $serializer; public function __construct( private readonly HttpKernelInterface $httpKernel, ) { $encoders = [new JsonEncoder()]; $normalizers = [new ObjectNormalizer()]; $this->serializer = new Serializer($normalizers, $encoders); } protected function sendServiceRequest( string $uri, array $query = [], array $requestBody = [], string $method = Request::METHOD_GET ): Response { foreach ([$query, $requestBody] as $payload) { $this->validatePayload($payload); } $request = new Request( query: $query, request: $requestBody, content: json_encode($requestBody, JSON_THROW_ON_ERROR), ); $request->setMethod($method); $request->server->set('REQUEST_URI', $uri); $request->attributes->set(self::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY, true); return $this->httpKernel->handle($request, HttpKernelInterface::SUB_REQUEST); } private function validatePayload($data): void { foreach ($data as $item) { if (is_array($item)) { $this->validatePayload($item); } elseif (!is_scalar($item) && !is_null($item)) { throw new BadPayloadException(); } } }}
You will notice the validatePayload method — the purpose is to forbid passing of non-scalar values for service API requests. Even though it might work for internal requests, it is useful to enforce this rule now to ensure a smooth transition to HTTP requests later.
Also, since we are adding new Symfony routes the standard way, they are automatically exposed to external requests, which we don’t want. To alleviate that, we create a special event handler:
# src/Common/EventListener/HideInternalApiListener.php<?phpdeclare(strict_types=1);namespace App\Common\EventListener;use App\Common\Client\AbstractSymfonyControllerResolvingClient;use Symfony\Component\EventDispatcher\Attribute\AsEventListener;use Symfony\Component\HttpKernel\Event\RequestEvent;use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;#[AsEventListener(event: 'kernel.request')]class HideInternalApiListener{ public function onKernelRequest(RequestEvent $event): void { $serviceApiUrlPattern = '/service-'; $comparisonResult = strncmp($event->getRequest()->getPathInfo(), $serviceApiUrlPattern, mb_strlen($serviceApiUrlPattern)); if (0 !== $comparisonResult) { return; } $secretKey = $event->getRequest()->attributes->get(AbstractSymfonyControllerResolvingClient::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY); if (true !== $secretKey) { throw new NotFoundHttpException(); } }}
It checks the request URI, and if it starts with /service- and does not have a special request attribute, returns 404.
Now, let’s add the first service client:
# src/Common/Client/RestaurantServiceClient.php<?phpdeclare(strict_types=1);namespace App\Common\Client;use App\Common\Dto\Order;use App\Common\Dto\Restaurant;use RuntimeException;class RestaurantServiceClient extends AbstractSymfonyControllerResolvingClient{ public function getRestaurant(int $restaurantId): ?Restaurant { $response = $this->sendServiceRequest('/service-restaurant/restaurants/'.$restaurantId); if (404 === $response->getStatusCode()) { return null; } if (200 !== $response->getStatusCode()) { throw new RuntimeException('Unexpected response code'); } return $this->serializer->deserialize($response->getContent(), Restaurant::class, 'json'); } public function acceptOrder(Order $orderDto): bool { $response = $this->sendServiceRequest( uri: '/service-restaurant/order/actions/accept', requestBody: $this->serializer->normalize($orderDto), method: 'POST' ); if (200 !== $response->getStatusCode()) { throw new RuntimeException('Unexpected response code'); } return $this->serializer->decode(data: $response->getContent(), format: 'json'); }}
And use it instead of the direct service call:
# src/Customer/Service/CustomerService.php readonly class CustomerService { public function __construct(- private RestaurantService $restaurantService,- private CourierService $deliveryService,- private EntityManagerInterface $entityManager+ private RestaurantServiceClient $restaurantServiceClient,+ private CourierServiceClient $courierServiceClient,+ private EntityManagerInterface $customerEntityManager ) { } public function createOrder(CreateOrderRequest $createOrderRequest): int {- if (!($restaurant = $this->restaurantService->getRestaurant($createOrderRequest->getRestaurantId()))) {+ if (!($restaurant = $this->restaurantServiceClient->getRestaurant($createOrderRequest->getRestaurantId()))) { throw new EntityNotFoundException(); } $newOrder = (new Order())- ->setRestaurant($restaurant)+ ->setRestaurantId($restaurant->getId()) ->setStatus(Order::STATUS_NEW);- if ($this->restaurantService->acceptOrder($newOrder)) {+ $this->customerEntityManager->persist($newOrder);+ $this->customerEntityManager->flush();++ $orderDto = new OrderDto($newOrder->getId(), $newOrder->getStatus(), $newOrder->getRestaurantId(), $newOrder->getDeliveryId());++ if ($this->restaurantServiceClient->acceptOrder($orderDto)) { $newOrder->setStatus(Order::STATUS_ACCEPTED);- $newDelivery = $this->deliveryService->createDelivery($newOrder);- $newOrder->setDelivery($newDelivery);+ $newDelivery = $this->courierServiceClient->createDelivery($orderDto);+ $newOrder->setDeliveryId($newDelivery->getId()); } else { $newOrder->setStatus(Order::STATUS_DECLINED); }- $this->entityManager->persist($newOrder);- $this->entityManager->flush();+ $this->customerEntityManager->persist($newOrder);+ $this->customerEntityManager->flush(); return $newOrder->getId(); } }
Done! This way, we’ve reduced the number of dependencies reported by Deptrac from 15 to 11. This is an example of iterative decoupling, where we introduce boundaries step by step. The rest of the dependencies in our example application are resolved the same way, but in real-world applications, there is usually more decision-making required regarding what to put in the Common namespace — e.g., general exceptions, DTOs, helpers, etc. It is typical to put in more things in the beginning and trim them out later on.
After the dependencies are resolved, it makes sense to commit the deptrac.yaml config file and configure a required check in your CI/CD to make sure no new dependencies are introduced, therefore boundaries between the modules are enforced.
Clean Deptrac report
You can see the full code example following this step here.
From modularized monolith to service-oriented architecture
Once we have our modules separated, migrating to services is somewhat trivial. For the sake of the example, I just copied the contents of the entire application into separate directories, then removed unneeded modules from the source and fixed a couple of config files. Here is the final structure:
courier-service/ <-- Separate service level (complete Symfony application) src/ Courier/ <-- Since module is inside an application ...customer-service/ src/ Customer/ ...deployment <-- Infrastructure configurations (e.g. Nginx config)restaurant-service/ src/ Restaurant/ ....envdocker-compose.override.ymldocker-compose.ymlDockerfileLICENSEMakefileREADME.md
I won’t list all the small changes in the configuration files but will highlight key elements.
First, the Nginx configuration for such a structure:
There you can see separate upstreams per service, as well as upstream selection based on the request URI.
Second, since we are switching from sub requests to real HTTP requests, we have to require symfony/http-client in our services and then adjust the base class for clients, the client itself, and the service API controller:
# courier-service/src/Common/Client/CustomerServiceClient.php-class CustomerServiceClient extends AbstractSymfonyControllerResolvingClient+class CustomerServiceClient extends AbstractHttpClient { public function changeOrderStatus(int $orderId, string $newOrderStatus): void {@@ -23,4 +23,9 @@ throw new RuntimeException('Unexpected response code'); } }++ protected function getServiceName(): string+ {+ return 'customer';+ } }
# customer-service/src/Customer/Controller/ServiceApiController.php+#[Route('/api/customer')] class ServiceApiController extends AbstractController { #[Route('/service-customer/orders', methods: 'POST')]
You will notice that we have to adjust the routes, so our Nginx can serve the internal requests as well. But since we switched from sub requests, we can no longer use the Symfony request attributes to secure our internal requests. In the interest of simplicity, I have added a secret key that is sent along with the request and then checked in the same HideInternalApiListener:
# customer-service/src/Common/EventListener/HideInternalApiListener.php #[AsEventListener(event: 'kernel.request')]-class HideInternalApiListener+readonly class HideInternalApiListener {+ public function __construct(+ #[Autowire('%api.secret.key%')]+ private string $apiSecretKey,+ ) {+ }+ public function onKernelRequest(RequestEvent $event): void {- $serviceApiUrlPattern = '/service-';+ $serviceApiUrlPattern = '/api/customer/service-customer/'; $comparisonResult = strncmp($event->getRequest()->getPathInfo(), $serviceApiUrlPattern, mb_strlen($serviceApiUrlPattern)); if (0 !== $comparisonResult) { return; }- $secretKey = $event->getRequest()->attributes->get(AbstractSymfonyControllerResolvingClient::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY);- if (true !== $secretKey) {+ $apiSecret = $event->getRequest()->headers->get('X-Api-Secret');+ if ($this->apiSecretKey !== $apiSecret) { throw new NotFoundHttpException(); } }
This is not ideal for production usage but serves as an example. A better approach can be implementing JWT to authenticate internal requests.
You can see the full code example following this step here.
From service-oriented to event-driven architecture
In this part, we will change our architecture to be more event driven. I specifically phrase it this way because, as mentioned above, we won’t dive deep into this architecture as it is outside of the scope of the article. Instead, we will focus on practical changes required to switch from direct service API calls to async messages.
What has to change will be more obvious if we look at the following sequence diagrams:
Create order action: sync services communication
We are going to turn this sequence into this (I have highlighted communication points that will become async):
Create order action: async services communication
The same goes for the “Change delivery status” action:
Change delivery status action: sync services communication
Only a slight change is required to make interservice communication async in this sequence:
Change delivery status action: async services communication
To implement this kind of communication, we will perform the following steps:
Introduce an async broker and consumers into our infrastructure.
Create messages in our Common namespace, and corresponding message handlers in our services.
Use message bus instead of service client calls for inter-module communication.
Let’s go step by step. We need to start by installing the Symfony Messenger component:
# other services have the same configuration except queue name and routingframework: messenger: serializer: default_serializer: messenger.transport.symfony_serializer transports: async: dsn: '%env(MESSENGER_TRANSPORT_DSN)%' options: queues: restaurant-service: ~ buses: default.bus: default_middleware: enabled: true allow_no_handlers: true
Add async broker and consumers:
# docker-compose.yml+ amqp-broker:+ image: rabbitmq:3+ restart: unless-stopped...+ courier-consumer:+ build:+ context: .+ working_dir: /usr/share/app+ restart: unless-stopped+ command: bin/console messenger:consume async -vv+ volumes:+ - ./courier-service/:/usr/share/app// consumers for the rest 2 services are added the same way...
There are several ways of utilizing asynchronous brokers, but we will use a fanout exchange (which is created by Symfony by default if not configured otherwise) and queue per service. This way, we have several advantages:
Producers send messages to an exchange, without needing to specify a routing key and without knowing who exactly will receive the message. This makes our messages look more like events and not background tasks.
Each service always receives all messages and reacts accordingly. It’s okay if it does not need to handle it — messages can be ignored, but it makes it simple to start handling additional messages at any moment if needed.
Finally, we can create our first message and change the way our services communicate:
# customer-service/src/Common/Message/OrderCreated.php<?phpdeclare(strict_types=1);namespace App\Common\Message;use App\Common\Dto\Order as OrderDto;readonly class OrderCreated{ public function __construct(private OrderDto $order) { } public function getOrder(): OrderDto { return $this->order; }}// all messages should be part of the "Common" namespace of each service// in our case, for the sake of the example, we just copy it// but in real application, it can (and should) be a package
# restaurant-service/src/Restaurant/MessageHandler/OrderCreatedHandler.php<?phpdeclare(strict_types=1);namespace App\Restaurant\MessageHandler;use App\Common\Message\OrderAccepted;use App\Common\Message\OrderCreated;use Symfony\Component\Messenger\Attribute\AsMessageHandler;use Symfony\Component\Messenger\MessageBusInterface;#[AsMessageHandler]readonly class OrderCreatedHandler{ public function __construct(private MessageBusInterface $messageBus) { } public function __invoke(OrderCreated $message) { // for the sake of the example, let's assume for now that the order can always be served $this->messageBus->dispatch(new OrderAccepted($message->getOrder())); // alternatively, we could dispatch this instead based on our business logic: // $this->messageBus->dispatch(new OrderDeclined()); }}
And now, we can adjust the code to use the message instead of a service client call:
# customer-service/src/Customer/Service/CustomerService.php namespace App\Customer\Service;-use App\Common\Client\CourierServiceClient; use App\Common\Client\RestaurantServiceClient; use App\Common\Dto\Order as OrderDto; use App\Common\Exception\EntityNotFoundException;+use App\Common\Message\OrderCreated; use App\Customer\Dto\CreateOrderRequest; use App\Customer\Entity\Order; use Doctrine\ORM\EntityManagerInterface;+use Symfony\Component\Messenger\MessageBusInterface; readonly class CustomerService { public function __construct( private RestaurantServiceClient $restaurantServiceClient,- private CourierServiceClient $deliveryServiceClient,- private EntityManagerInterface $customerEntityManager+ private EntityManagerInterface $customerEntityManager,+ private MessageBusInterface $messageBus, ) { }@@ -36,17 +37,11 @@ $orderDto = new OrderDto($newOrder->getId(), $newOrder->getStatus(), $newOrder->getRestaurantId(), $newOrder->getDeliveryId());- if ($this->restaurantServiceClient->acceptOrder($orderDto)) {- $newOrder->setStatus(Order::STATUS_ACCEPTED);- $newDelivery = $this->deliveryServiceClient->createDelivery($orderDto);- $newOrder->setDeliveryId($newDelivery->getId());- } else {- $newOrder->setStatus(Order::STATUS_DECLINED);- }- $this->customerEntityManager->persist($newOrder); $this->customerEntityManager->flush();+ $this->messageBus->dispatch(new OrderCreated($orderDto));+ return $newOrder; }
Done! The rest of the communication is changed in the same way. After that, we can send a request to the “Create order” endpoint and see that the messages are indeed sent to all services:
Also, we will notice the communication in the logs:
customer-service_1 | Matched route "app_customer_customerapi_createorder".customer-service_1 | Request: "GET http://nginx/api/restaurant/service-restaurant/restaurants/2"restaurant-service_1 | Matched route "app_restaurant_serviceapi_getrestaurant".customer-service_1 | Sending message App\Common\Message\OrderCreated with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransportcustomer-consumer_1 | Received message App\Common\Message\OrderCreatedcustomer-consumer_1 | No handler for message App\Common\Message\OrderCreatedcustomer-consumer_1 | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).courier-consumer_1 | Received message App\Common\Message\OrderCreatedcourier-consumer_1 | No handler for message App\Common\Message\courier-consumer_1 | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).restaurant-consumer_1 | Received message App\Common\Message\OrderCreatedrestaurant-consumer_1 | Sending message App\Common\Message\OrderAccepted with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransportrestaurant-consumer_1 | Message App\Common\Message\OrderCreated handled by App\Restaurant\MessageHandler\OrderCreatedHandler::__invokerestaurant-consumer_1 | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).restaurant-consumer_1 | Received message App\Common\Message\OrderAcceptedrestaurant-consumer_1 | No handler for message App\Common\Message\OrderAcceptedrestaurant-consumer_1 | App\Common\Message\OrderAccepted was handled successfully (acknowledging to transport).customer-consumer_1 | Received message App\Common\Message\OrderAcceptedcourier-consumer_1 | Received message App\Common\Message\OrderAcceptedcustomer-consumer_1 | Message App\Common\Message\OrderAccepted handled by App\Customer\MessageHandler\OrderAcceptedHandler::__invokecustomer-consumer_1 | App\Common\Message\OrderAccepted was handled successfully (acknowledging to transport).courier-consumer_1 | No handler for message App\Common\Message\DeliveryCreatedcourier-consumer_1 | Message App\Common\Message\OrderAccepted handled by App\Courier\MessageHandler\OrderAcceptedHandler::__invokecourier-consumer_1 | Sending message App\Common\Message\DeliveryCreated with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransport...
Additionally, after the final cleanup, we can see that both the courier and restaurant services no longer need service clients as they don’t need to communicate with other services directly. Neat!
You can see the full code example following this step here.
Internationalization
Internationalization is yet another and very important part of application development. We have not really covered it in today’s article as this is a broad topic of its own. However, if you are interested, there are numerous tutorials on i18n in our blog covering all major programming languages and technologies.
Also, you might think about using a dedicated translation management system to help you alleviate some typical issues and pitfalls when translating the application. We at Lokalise are happy to assist you! Grab your free 14-day trial on the signup page and don’t hesitate to reach out to us via the chat widget to learn about our goodies.
Conclusion
In this article, we observed application architecture transformation through several stages. Starting with a monolith, changing to a modular monolith, then separate services, and ending with an event-driven architecture. We focused on the technical details of such a transition.
However, it’s worth noting that in real-world applications, there will be a lot more work, more edge cases, and more decision-making. Therefore, I suggest treating this article not as “to-do” guidance but as a practical example showing that this process can (and should) be iterative and predictable. The biggest challenge of such architectural changes is often not the change itself, but justification and building a proper process that allows engineers and other involved parties to believe in the success of the change and maintain focus through it. I hope this article gives at least a glimpse of that.
Ilya is a seasoned Staff Software Engineer at Lokalise, with expertise in PHP, Nginx, SQL, NoSQL storages, Elasticsearch, and high-load systems, leveraging React for frontend development. He also has experience in Node.js and is exploring Golang, cloud technologies, and microservices, committed to writing clean, precise, and robust code.
Throughout his career, from roles at Realforce Solutions SA to Hearst Shkulev Digital, he advanced from PHP developer to team lead, combining technical acumen with strong management skills. With a client-oriented and business-driven mindset, he ensures projects align with company goals.
Outside work, Ilya enjoys exercising, cycling, and exploring video games and anime. For him, software engineering is not just a career but a lifelong passion, always seeking projects that inspire and engage.
Ilya is a seasoned Staff Software Engineer at Lokalise, with expertise in PHP, Nginx, SQL, NoSQL storages, Elasticsearch, and high-load systems, leveraging React for frontend development. He also has experience in Node.js and is exploring Golang, cloud technologies, and microservices, committed to writing clean, precise, and robust code.
Throughout his career, from roles at Realforce Solutions SA to Hearst Shkulev Digital, he advanced from PHP developer to team lead, combining technical acumen with strong management skills. With a client-oriented and business-driven mindset, he ensures projects align with company goals.
Outside work, Ilya enjoys exercising, cycling, and exploring video games and anime. For him, software engineering is not just a career but a lifelong passion, always seeking projects that inspire and engage.
Libraries and frameworks to translate JavaScript apps
In our previous discussions, we explored localization strategies for backend frameworks like Rails and Phoenix. Today, we shift our focus to the front-end and talk about JavaScript translation and localization. The landscape here is packed with options, which makes many developers a
An SRT file is a plain text file used to add subtitles to videos. It’s one of the simplest and most common formats out there. If you’ve ever turned on captions on a YouTube video, there’s a good chance it was using an SRT file behind the scenes. People use SRT files for all kinds of things: social media clips, online courses, interviews, films, you name it. They’re easy to make, easy to edit, and they work pretty much everywhere without hassle. In this post, we’ll
Character encoding: Types, UTF-8, Unicode, and more explained
In this article, we’ll explore various types of character encoding used in the world of information technology. We’ll break down why encoding matters, explain how they function, and highlight the key differences between ASCII, UTF-8, UTF-16, and the Unicode Standard. Understanding these differences is essential for correctly handling text in any software application, especially when working with localized time and date formats that rely on proper ch