Share
Practical insight into modulithicย architecture
In software architecture, it is always about finding the best solution for your use case and your application in your specific context. Based on that, you have the challenge to find the right balance between flexibility and simplicity. We often must decide between a monolithic system and a microservice architecture. For a long time, the monolithic architecture has been the way to go. Then, microservices came up and most architectures followed way. Today I would like to present an architecture that attempts to combine the advantages of both paradigms.
Using a concrete example project, I will discuss the advantages and challenges of a modular architecture. I will present a possible approach to the appropriate structuring of Symfony applications using modulithic architecture and show how it can help to map modern software development requirements.
Max Beckers
Transcript
40 slides
-
Contact: ๐ @maxbbeckers [email protected] linkedin.com/in/maximilianbeckers Max Beckers Solutions Architect โข
Working for / โข Living near Kiel, Germany โข Symfony Experience since 2012 -
Software architecture is always about finding the best solution for
your use case and your application in your specific context. The challenge is to find the right balance between simplicity and flexibility. ๐ @maxbbeckers -
Finding its place between Monoliths and Microservice Modulithic Architecture Modular
Monolith ๐ @maxbbeckers -
๐ @maxbbeckers Monolith โ Modulith โ Microservice โ Overview Microservices
Modulith Monolith Multiple Both possible Single Codebase Multiple Single Single Releases Multiple Single Single Deployments Domain / Business centered Domain / Business centered Technical design Design Principles Orchestration & Monitoring Releases / Coordination Understanding Code Complexity Network overhead Direct calls Direct calls Performance Services + Integration Modules + Integration Whole codebase Testing -
๐ @maxbbeckers Monolith โ Modulith - Microservice Microservices Modulith Monolith
โ โ โ Simplicity โ โ* โ Scalability โ โ โ Low Coupling โ* โ* โ* Maintainability โ โ โ Single point of failure โ โ* โ Free in technology usage โ โ โ Transaction Handling -
๐ @maxbbeckers Architecture Overview API GATE WAY Payment Processing Order
Management Customer Management SEPA Mandate Payment Provider -
๐ @maxbbeckers Symfony Application Overview Use-Case based Structure instead of
technical namespaces at the first level. -
๐ @maxbbeckers Generate Code from the API #!/usr/bin/env bash openapi-generator-cli
generate \ -i ./public-contract-definition.yaml \ -g php \ -o ./generated \ --additional-properties=\ invokerPackage=Worldline\\Payments\\Api\\V2,\ modelPackage=Models,\ artifactVersion=2.279.0,\ composerPackageName=worldline/payment-api,\ licenseName=MIT -
๐ @maxbbeckers Composer part "repositories": [ { "type": "path", "url":
"api-spec/generated", "options": { "symlink": true } } ], $ composer require worldline/payment-api:@dev ./composer.json has been updated Running composer update worldline/payment-api Loading composer repositories with package information Updating dependencies -
๐ @maxbbeckers API Gateway "Internal" namespace denotes module internal functionality
โ cannot be accessed outside the module context. -
๐ @maxbbeckers Modulith internal APIs - Objects Modules have data
structures defined by DTOs -
๐ @maxbbeckers Modulith internal APIs - Services Services in a
module are called using interfaces. In a Microservices context, this could be the OpenAPI Spec. -
๐ @maxbbeckers Modulith internal APIs - Messages Modules can consume
messages defined in Message. namespace App\Order\Message; use App\Order\Dto\OrderDto; class PersistOrderEvent { public function __construct( private readonly OrderDto $orderDto ) { } public function getOrderDto(): OrderDto { return $this->orderDto; } } -
๐ @maxbbeckers Modulith internal APIs - Overview โข DTOs โ
Data Structure for the API of the Module โข Interfaces โ Endpoints for the API of the Module โข Messages โ Async Communication API of the Module โข Exceptions โ Error Responses of the Module -
๐ @maxbbeckers #[Route(name: "app_v2_payments_")] class PaymentsController extends AbstractController { public
function __construct( private readonly PaymentServiceInterface $paymentService, private readonly PaymentMapper $paymentMapper ) {} #[Route('/v2/{merchantId}/payments', name: 'create_payment', methods: ['POST'])] public function create(Request $request, string $merchantId): JsonResponse { /** @var CreatePaymentRequest $createPaymentRequest */ $createPaymentRequest = ObjectSerializer::deserialize($request->getContent(), CreatePaymentRequest::class); $paymentRequest = $this->paymentMapper->mapCreatePaymentRequestToPaymentDTO($createPaymentRequest, $merchantId); $paymentResponse = $this->paymentService->pay($paymentRequest); $response = $this->paymentMapper->mapPaymentDTOToPaymentResponse($paymentResponse); return $this->json(ObjectSerializer::sanitizeForSerialization($response), 201); } } Payments Controller -
๐ @maxbbeckers #[Route(name: "app_v2_payments_")] class PaymentsController extends AbstractController { public
function __construct( private readonly PaymentServiceInterface $paymentService, private readonly PaymentMapper $paymentMapper ) {} #[Route('/v2/{merchantId}/payments', name: 'create_payment', methods: ['POST'])] public function create(Request $request, string $merchantId): JsonResponse { /** @var CreatePaymentRequest $createPaymentRequest */ $createPaymentRequest = ObjectSerializer::deserialize($request->getContent(), CreatePaymentRequest::class); $paymentRequest = $this->paymentMapper->mapCreatePaymentRequestToPaymentDTO($createPaymentRequest, $merchantId); $paymentResponse = $this->paymentService->pay($paymentRequest); $response = $this->paymentMapper->mapPaymentDTOToPaymentResponse($paymentResponse); return $this->json(ObjectSerializer::sanitizeForSerialization($response), 201); } } Payments Controller -
๐ @maxbbeckers #[Route(name: "app_v2_payments_")] class PaymentsController extends AbstractController { public
function __construct( private readonly PaymentServiceInterface $paymentService, private readonly PaymentMapper $paymentMapper ) {} #[Route('/v2/{merchantId}/payments', name: 'create_payment', methods: ['POST'])] public function create(Request $request, string $merchantId): JsonResponse { /** @var CreatePaymentRequest $createPaymentRequest */ $createPaymentRequest = ObjectSerializer::deserialize($request->getContent(), CreatePaymentRequest::class); $paymentRequest = $this->paymentMapper->mapCreatePaymentRequestToPaymentDTO($createPaymentRequest, $merchantId); $paymentResponse = $this->paymentService->pay($paymentRequest); $response = $this->paymentMapper->mapPaymentDTOToPaymentResponse($paymentResponse); return $this->json(ObjectSerializer::sanitizeForSerialization($response), 201); } } Payments Controller -
๐ @maxbbeckers โข Each module has its own tables โข
Modules are not integrated at the database level (SELECTS / JOINS using tables of other modules are not allowed) 4 ways of data isolation: โข Different type of persistence (e.g. MySQL, Postgres, Redis, MongoDB, โฆ) โข Separate database for each module โข Separate schema for each module (used in the example) โข Separate tables in the same schema (I would not recommend) Persistence & Data Rules -
๐ @maxbbeckers Database setup API GATE WAY Payment Processing Order
Management Customer Management SEPA Mandate Payment Order Customer Mandate -
๐ @maxbbeckers Entities <?php namespace App\Mandate\Internal\Entity; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UuidType; use Symfony\Component\Uid\Uuid; #[ORM\Entity] class Customer { #[ORM\Id] #[ORM\Column(type: UuidType::NAME, unique: true)] private Uuid $id; #[ORM\Column(length: 64)] private string $merchantId; #[ORM\Column(length: 36)] private string $customerReference; #[ORM\Column(length: 34)] private string $iban; #[ORM\OneToMany(targetEntity: Mandate::class, mappedBy: 'customer')] private array $mandates; -
๐ @maxbbeckers Database config .env CUSTOMER_DATABASE_URL="postgresql://customer:[email protected]:5432/customer?serverVersion=16&charset=utf8" MANDATE_DATABASE_URL="postgresql://mandate:[email protected]:5432/mandate?serverVersion=16&charset=utf8" ORDER_DATABASE_URL="postgresql://order:[email protected]:5432/order?serverVersion=16&charset=utf8" PAYMENT_DATABASE_URL="postgresql://payment:[email protected]:5432/payment?serverVersion=16&charset=utf8" Can
be different for each module Can be different for each module -
๐ @maxbbeckers Database config doctrine: dbal: connections: customer: url: '%env(resolve:CUSTOMER_DATABASE_URL)%'
mandate: url: '%env(resolve:MANDATE_DATABASE_URL)%' order: url: '%env(resolve:ORDER_DATABASE_URL)%' payment: url: '%env(resolve:PAYMENT_DATABASE_URL)%' default_connection: payment orm: entity_managers: customer: connection: customer mappings: Customer: type: attribute is_bundle: false dir: '%kernel.project_dir%/src/Customer/Internal/Entity' prefix: 'App\Customer\Internal\Entity' alias: Customer -
๐ @maxbbeckers Doctrine Migrations #config/packages/migrations/migrations_customer.yaml em: customer migrations_paths: App\Migrations\Customer: 'migrations/Customer'
table_storage: table_name: migration_versions $ php bin/console doctrine:migrations:diff --em=customer --configuration=config/packages/migrations/migrations_customer.yaml Generated new migration class to "migrations/Customer/Version20240502123126.php" To run just this migration for testing purposes, you can use migrations:execute --up 'App\\Migrations\\Customer\\Version20240502123126' To revert the migration you can use migrations:execute --down 'App\\Migrations\\Customer\\Version20240502123126' -
๐ @maxbbeckers Messenger setup API GATE WAY Payment Processing Order
Management Customer Management SEPA Mandate Payment Order Customer -
๐ @maxbbeckers Queue config .env CUSTOMER_MESSENGER_TRANSPORT_DSN=amqp://customer:password@localhost:5672/customer/messages ORDER_MESSENGER_TRANSPORT_DSN=amqp://order:password@localhost:5672/order/messages PAYMENT_MESSENGER_TRANSPORT_DSN=amqp://payment:password@localhost:5672/payment/messages framework: messenger:
transports: customer: dsn: '%env(CUSTOMER_MESSENGER_TRANSPORT_DSN)%' order: dsn: '%env(ORDER_MESSENGER_TRANSPORT_DSN)%' payment: dsn: '%env(PAYMENT_MESSENGER_TRANSPORT_DSN)%' routing: 'App\Customer\Message\PersistCustomerEvent': customer 'App\Order\Message\PersistOrderEvent': order 'App\Payment\Internal\Message\PersistPaymentEvent': payment -
๐ @maxbbeckers Scaling Consumers with Supervisor $ sudo apt-get install
supervisor ;/etc/supervisor/conf.d/messenger-worker-payment.conf [program:messenger-consume] command=php /home/www/bin/console messenger:consume payment --time-limit=3600 user=ubuntu numprocs=2 startsecs=0 autostart=true autorestart=true startretries=10 process_name=%(program_name)s_%(process_num)02d -
๐ @maxbbeckers Flexibility API GATE WAY Payment Processing Order Management
Customer Management SEPA Mandate -
๐ @maxbbeckers Add a new module API GATE WAY Payment
Processing Order Management Customer Management SEPA Mandate Invoicing -
๐ @maxbbeckers Modulith is an architectural alternative between Monolith and
Microservices โข Simplicity & Maintainability โข Low coupling & clear boundaries โข Less complexity Monolith: Understanding code, Testing, Clear boundaries Microservices: Easier to configure, no network overhead The Complexity of the business code itself stays of course ๐ Summary -
๐ @maxbbeckers Donโt start with microservices โYou shouldn't start a
new project with microservices, even if you're sure your application will be big enough to make it worthwhile.โ Martin Fowler S