Budowanie modularnego monolitu z myślą o skali
Często, kiedy budujemy greenfieldowy produkt, szczególnie w startupowym środowisku, musimy poruszać się szybko i być gotowi na zmiany. Dlatego dla wielu zespołów solidna architektura i przyszła skalowalność często nie są priorytetem, czasami nie stanowi to problemu, jeśli biznes stojący za projektem nie jest złożony. Ale... co, jeśli problem, który rozwiązujesz, jest złożony i od pierwszego dnia obejmuje wiele domen albo musi operować na dużych wolumenach danych? W takim przypadku dobra architektura odgrywa bardzo ważną rolę. Bez niej twój zespół szybko się pogubi i będzie działał wolniej, kończąc na debugowaniu błędów spowodowanych przez spaghetti code oraz ciągłym przepisywaniu części aplikacji przy każdym pivocie i każdej nowej dużej funkcjonalności, jednocześnie zmagając się z modelem danych i lawinowo rosnącym długiem technicznym od samego początku. Z drugiej strony, na tak wczesnym etapie projektu wydzielenie osobnych mikroserwisów dla każdej domeny byłoby kosztowne i oznaczałoby dodatkowy narzut operacyjny, więc potrzebujemy czegoś pośrodku. Na szczęście mamy architekturę Modularngo Monolitu.
Czym jest architektura Modular Monolith w kilku słowach
Jeśli chcemy podsumować to w kilku słowach, Modularny Monolith to architektura monolitycznej aplikacji złożonej z luźno powiązanych modułów/usług, które korzystają z tej samej współdzielonej bazy daych, ale zachowują izolację swoich modeli danych i komunikują się ze sobą przez API lub eventy. Możesz myśleć o nim jak o mikroserwiso-poodbnych bytach spakowanych do jednej aplikacji, która stanowi pojedynczą jednostkę wdrożeniową.
Node.js to bardzo elastyczne środowisko, świetne do implementacji takiej architektury, zarówno samodzielnie, jak i z użyciem frameworków takich jak Nest.js, które promują architekturę modułową i dobrze pasują do Modularnego Monolitu.
Dodatkową zaletą Modularnego Monolitu jest to, że kiedy popełnisz błąd podczas wydzielania serwisów albo chcesz pozbyć się jednego z już istniejących, często wystarczy skorygować strukturę projektu i granice modułów, a potem iść dalej.
Najpierw zdefiniuj Bounded Context
Jeśli zdecydowałeś się rozpocząć projekt w architekturze Modular Monolith, nie wskakuj od razu w kod i nie zaczynaj ślepo tworzyć modułów. Zbierz interesariuszy, ekspertów domenowych i członków zespołu, aby zdefiniować bounded contexts, które pomogą ci uporządkować aplikację według domen biznesowych.
Bounded Context to kluczowe pojęcie w DDD służące do wyznaczania ścisłych granic biznesowych i zapobiegania nakładaniu się logiki między różnymi częściami systemu. Ten koncept pozwala definiować niezależne modele domenowe; na przykład User może oznaczać coś innego w kontekście Identity(auth), gdzie przechowuje informacje o rolach w aplikacji i podłączonych identity providerach, podczas gdy w kontekście Billing przechowuje informacje o danych podatkowych i metodach płatności.
Separacja danych
Separacja danych jest kluczowa przy budowaniu modularnego monolitu i jeśli zdefiniowałeś w swojej aplikacji bounded contexts, rozdzielenie modeli powinno być znacznie łatwiejsze, jak technicznie to zrobić?
Zacznijmy od początku. Gdy myślimy o monolicie, myślimy o aplikacji operującej na jednym modelu danych. Na przykład mamy typowy przypadek aplikacji e-commerce, w której występują encje takie jak user, product, merchant, delivery, invoice; każda z tych encji ma właściwości pochodzące z każdego kontekstu. Przyjrzyjmy się bliżej userowi:
typescript
export interface GodUserProps {
// --- IDENTITY DOMAIN ---
id: string;
email: string;
passwordHash: string;
isMfaEnabled: boolean;
// --- ORDER DOMAIN ---
currentCartItems: { sku: string; qty: number }[];
activeDiscountCode: string | null;
lifetimeOrderCount: number;
// --- LOGISTICS DOMAIN ---
streetAddress: string;
postalCode: string;
countryCode: string;
shippingInstructions: string;
}
export class GodUser {
private props: GodUserProps;
constructor(props: GodUserProps) {
this.props = props;
}
// --- IDENTITY DOMAIN ---
public rotatePassword(newHash: string): void {
if (newHash === this.props.passwordHash) {
throw new Error("Cannot reuse old password");
}
this.props.passwordHash = newHash;
}
public enableMfa(): void {
this.props.isMfaEnabled = true;
}
// --- ORDER DOMAIN ---
public addItemToCart(sku: string, qty: number): void {
if (qty <= 0) throw new Error("Quantity must be positive");
this.props.currentCartItems.push({ sku, qty });
}
public incrementOrderHistory(): void {
this.props.lifetimeOrderCount += 1;
this.props.currentCartItems = [];
}
// --- LOGISTICS DOMAIN ---
public isPoBoxAddress(): boolean {
return this.props.streetAddress.toLowerCase().includes("p.o. box");
}
public validateDeliveryRoute(): void {
if (this.isPoBoxAddress() && this.props.countryCode === "US") {
throw new Error(
"Logistics failure: Ground couriers cannot deliver to US PO Boxes",
);
}
}
}Celowo dodałem komentarze, żeby podkreślić różne domeny wrzucone do tego usera; kiedy masz więcej takich encji i aplikacja rośnie, bardzo trudno jest nadążyć z dodawaniem nowych funkcji, nie rozwalając logiki, te same problemy dotyczą czytania tego kodu oraz testowania.
W wielu architekturach mikroserwisowych mielibyśmy osobną bazę danych dla każdego serwisu albo domeny, a w tych bazach encja User oznaczałaby coś innego w zależności od kontekstu biznesowego.
Jedną z zalet modularnego monolitu jest niski narzut operacyjny, a używanie osobnej bazy danych dla każdej domeny znacząco ogranicza tę korzyść. Istnieją jednak rozwiązania pozwalające osiągnąć taką izolację w obrębie jednej bazy danych.
Wzorzec konwencji nazewniczej
Pierwsze podejście polega na wymuszeniu konwencji nazewniczej dla encji. Na przykład naszego god usera dzielimy w bazie danych na 3 tabele/kolekcje, takie jak: identity_users, order_users, logistics_users. Jak widać, kontekst znajduje się na początku nazwy tabeli i w ten sposób dane są odseparowane. Twarda zasada jest tutaj taka, że nie powinno być żadnych joinów między tymi tabelami, a każdy moduł powinien operować wyłącznie na swoim własnym modelu danych. Rzeczy takie jak JOIN identy_users.id = order_users.id są surowo zabronione.
Wadami są oczywiście słaba izolacja, pilnowana jedynie przez konwencję, a nie wymuszana przez silnik bazy danych, oraz to, że trudniej będzie wydzielić mikroserwisy. Za to to podejście jest bardzo szybkie i proste i nie wymaga żadnych specjalnych funkcji bazy danych, co w zależności od tego, jaka baza jest używana w systemie, może być zaletą. Na przykład w MongoDB nie ma schema'y w takim sensie jak w PostgreSQL, a żeby naprawdę odseparować dane, trzeba by tworzyć osobną bazę danych dla każdej domeny, co dokłada złożoność związaną z wyborem właściwej bazy dla danej domeny i zarządzaniem wzorcami dostępu w kodzie aplikacji.
Wzorzec scheme'y dla każdego modułu
W bazie danych takiej jak PostgreSQL możemy zastosować osobną scheme dla każdego modułu i uzyskać dobrą izolację danych bez zaśmiecania nazw tabel specjalnym prefiksem kontekstu. Kiedy przyjdzie czas na wydzielenie mikroserwisu, możesz łatwo przenieść schema'e do osobnej bazy danych i gotowe. Zasada braku joinów nadal obowiązuje.
Główna wada polega na tym, że nie jest to wspierane przez wszystkie bazy danych, a sam fakt, że jest to funkcja specyficzna dla wybranych baz danych, może być problemem.
Komunikacja między modułami
Załóżmy, że chciałbym zaimplementować architekturę event-driven — czy jest to możliwe w modularnym monolicie? Oczywiście, jest, a przy odpowiedniej implementacji przejście w przyszłości do architektury mikroserwisowej powinno być dzięki temu łatwiejsze. Jednocześnie w Node.js nie musimy koniecznie używać dodatkowej infrastruktury, takiej jak message brokery; możemy zacząć od bardzo prostych rzeczy, jak mechanizm eventów in-memory: EventEmitter, a później podmienić to rozwiązanie, jeśli pojawi się potrzeba wydzielenia mikroserwisu.
Musimy pamiętać, że nasz mechanizm in-memory powinien być ukryty za interfejsem i powinien stanowić osobną paczkę, która pozwoli nam podmienić implementację tego mehanizmu, np. zastąpić go przez RabbitMQ.
Możemy też zaimplementować komunikację synchroniczną, ale na tym etapie nie chcemy korzystać z żadnych wywołań sieciowych. Aby osiągnąć ten cel, musimy operować na abstrakcjach, które będą enkapsulować granicę komunikacji między modułami, więc na przykład moduł A, który wywołuje moduł B, będzie miał adapter, który wywoła port modułu B. Taki port jest czymś w rodzaju interfejsu, który wystawia API funkcji dostarczanych przez moduł B. Gdy spojrzymy na naszą aplikację e-commerce i założymy, że moduł Order musi sprawdzić czy uzytkownik ma włączone MFA w module Identity, powinniśmy:
- Przygotować DTO i interfejsy jako kontrakt:
typescript
// shared/contracts/identity-port.interface.ts
export interface IdentityUserDTO {
id: string;
email: string;
isMfaEnabled: boolean;
}
// The Port: The public facade interface exposed by the Identity Module
export interface IdentityPort {
getUserProfile(userId: string): Promise<IdentityUserDTO>;
}- Zaimplementować port w module Identity:
typescript
// modules/identity/identity.facade.ts
import { IdentityPort, IdentityUserDTO } from "@shared/contracts";
import { UserService } from "./services/user.service"; // Internal import
export class IdentityFacade implements IdentityPort {
// Injects the actual internal service of the Identity module
constructor(private readonly userService: UserService) {}
async getUserProfile(userId: string): Promise<IdentityUserDTO> {
// 1. Delegate the work to the real internal domain service
const user = await this.userService.findById(userId);
// 2. Map the internal database entity to the clean public DTO
return {
id: user.id,
email: user.email,
isMfaEnabled: user.isMfaEnabled,
};
}
}- Zaimplementować adapter w module Order, który będzie używany do wywoływania fasady modułu Identity:
typescript
// modules/ordering/adapters/local-identity.adapter.ts
import { IdentityPort, IdentityUserDTO } from "@shared/contracts";
import { IdentityFacade } from "@modules/identity";
// This adapter wraps the direct in-memory call to the Identity module
export class LocalIdentityAdapter implements IdentityPort {
constructor(private readonly identityFacade: IdentityFacade) {}
async getUserProfile(userId: string): Promise<IdentityUserDTO> {
// Direct in-memory execution. Safe, fast, and networkless.
return this.identityFacade.getUserProfile(userId);
}
}- Użyć adaptera tam, gdzie jest potrzebny w module Order, na przykład:
typescript
// modules/ordering/services/checkout.service.ts
import { IdentityPort } from "@shared/contracts";
export class CheckoutService {
// Injected via abstract Port contract, NOT the concrete Identity class
constructor(private readonly identityPort: IdentityPort) {}
async executeOrder(userId: string, cartItems: any[]): Promise<void> {
// Synchronously fetches profile data across module boundaries safely
const userProfile = await this.identityPort.getUserProfile(userId);
if (!userProfile.isMfaEnabled) {
throw new Error(
"Security policy violation: MFA must be enabled to check out.",
);
}
// Continue with ordering domain logic using 'order_users' collection...
console.log(`Processing order for user: ${userProfile.email}`);
}
}Główna logika biznesowa pozostaje w pełni odseparowana i kiedy przyjdzie czas, aby wydzielić moduły do mikroserwisów, jedyne, co musimy zrefaktoryzować, to:
- W module Identity wprowadzamy kontroler, który może wywoływać fasadę i wystawić ją przez HTTP lub gRPC:
typescript
// apps/identity-service/src/controllers/user.controller.ts
import { Request, Response } from "express";
import { IdentityFacade } from "@lib/identity";
export class UserController {
constructor(private readonly identityFacade: IdentityFacade) {}
async handleGetUserProfile(req: Request, res: Response): Promise<Response> {
try {
const userId = req.params.id;
// The HTTP layer delegates directly to the existing, trusted facade
const userProfileDto = await this.identityFacade.getUserProfile(userId);
return res.status(200).json(userProfileDto);
} catch (error) {
const message =
error instanceof Error ? error.message : "Internal server error";
return res.status(500).json({ message });
}
}
}- W module Order musimy podmienić lokalny adapter na zdalny, na przykład adapter HTTP:
typescript
// apps/ordering-service/src/adapters/http-identity.adapter.ts
import { IdentityPort, IdentityUserDTO } from "@shared/contracts";
import axios from "axios";
export class HttpIdentityAdapter implements IdentityPort {
// points to the newly created deployment endpoint of the identity app
private readonly identityServiceUrl = process.env.IDENTITY_SERVICE_URL;
async getUserProfile(userId: string): Promise<IdentityUserDTO> {
const response = await axios.get<IdentityUserDTO>(
`${this.identityServiceUrl}/api/v1/users/${userId}`,
);
return response.data;
}
}Wykorzystanie sprawdzonych wzorców architektonicznych
Poprzedni przykład komunikacji doprowadził nas do punktu, w którym musimy porozmawiać o tym, jak sprawdzone wzorce mogą pomóc nam zbudować modularny monolit przygotowany na przyszłość. Aby nasze moduły dało się łatwo przekształcić w mikroserwisy, musimy mieć sposób na całkowite odseparowanie logiki biznesowej od infrastruktury, ponieważ wiemy, że to właśnie ona powinna być w tym przypadku jedynym obszarem zmian, bo modularny monolit składa się z logicznie uporządkowanych modułów w ramach jednej wdrażalnej aplikacji, podczas gdy mikroserwisy są systemem niezależnie wdrażanych usług - z tego wynika, że na etapie migracji do mikroserwisów to infrastruktura powinna być jedyną rzeczą, która się zmieni.
Wyobraźmy sobie, że mamy use case, który zarządza zamówieniami i intensywnie modyfikuje dane w bazie, musimy przenieść go z monolitu do osobnego mikroserwisu. W najlepszym scenariuszu chcielibyśmy zmienić wyłącznie kod związany z infrastrukturą, przy minimalnym wysiłku i maksymalnym bezpieczeństwie dla logiki biznesowej, mając pewność, że nie wprowadziliśmy żadnych krytycznych błędów. Kiedy zespół ma taką pewność, będzie działał szybciej podczas migracji do mikroserwisów.
Testy
Wspomniałem o pewności w poprzednim akapicie - tutaj testy dadzą ci kolejny filar pewności podczas migracji, ale tylko wtedy, gdy są dobrze ustrukturyzowane i rozdzielone. W testach również nie powinniśmy mieszać logiki biznesowej z kodem infrastruktury, a także zadbać o smoke testy obejmujące cały mikroserwis, aby po migracji mieć pewność, że nowa infrastruktura działa zgodnie z oczekiwaniami.
Przy przechodzeniu na mikroserwisy zasady dotyczące testów są podobne jak w przypadku kodu produkcyjnego. Testy przypadków użycia i logiki biznesowej powinny w większości przechodzić bez zmian; w większości przypadków zmiany będą potrzebne w testach związanych z infrastrukturą. Jeśli po migracji te dwa warunki są spełnione, to masz mocny sygnał, że migracja się udała.
Podsumowanie
Obecnie dużo mówimy o szybkim budowaniu przez wrzucanie PRD do promptów. Znacznie rzadziej mówimy jednak o skalowaniu takich aplikacji. Pamiętaj: garbage in, garbage out. Jeśli nie dbasz o architekturę swojej aplikacji, a myślisz o projekcie, który ma się skalować, to prędzej czy później zła architektura znacząco zwiększy prawdopodobieństwo, że zarówno narzędzia AI, jak i deweloperzy będą się męczyć, produkując więcej bezużytecznego slopu, a Twój team będzie miał problem ze zrozumieniem logiki biznesowej, bo będzie ona wymieszana ze wszystkim innym w codebase'ie. Ułożenie architektury od pierwszego dnia kosztuje bardzo niewiele w porównaniu z późniejszym sprzątaniem długu technicznego; nawet jeśli ostatecznie nie zdecydujesz się w przyszłości na przejście do mikroserwisów, dajesz sobie znacznie większą szansę na to, że skończysz z technicznie niezawodnym i skalowalnym produktem, który rozwiązuje Twój problem biznesowy albo problem biznesowy firmy, dla której pracujesz, zamiast powodować blokery i błędy.
