Produkt i jego kontekst rynkowy#
Typowy klient w branży produkcyjno-dystrybucyjnej już używa Fakturowni do faktur, ma własny WMS od dostawcy regałów, importuje CSV z TMS przewoźnika, prowadzi HR w Excelu. Brakuje warstwy, która to wszystko spina i daje jeden widok operacyjny — kto co produkuje, gdzie to leży, dokąd to jedzie, kto za to zapłacił. MTCT to ta warstwa. Z możliwością pełnego natywnego stacku dla firm, które dopiero zaczynają.
Dostarczyć modułową platformę B2B do zarządzania łańcuchem dostaw — taką, w której każdy moduł da się odłączyć i podłączyć do istniejącego u klienta systemu. Zamiast wymuszać "rip and replace", MTCT spina to, co klient już ma — Fakturownię do faktur, zewnętrzny WMS, ERP, HR — i wypełnia luki natywnymi modułami tam, gdzie klient ich nie ma. Architektura: hexagonal + CQRS + Domain Events, każda integracja zewnętrzna za portem, każdy moduł testowany na trzech poziomach (unit, BDD, integration z Testcontainers).
Co musieliśmy rozwiązać#
Platforma multi-modularnego rodzaju ma sens tylko wtedy, kiedy moduły są naprawdę wymienne — a nie wtedy, kiedy "mówimy, że są". To wymagało wcześnie podjętych, dyscyplinujących decyzji architektonicznych. Każda z dwunastu pozycji niżej to konkretne wyzwanie, na które musieliśmy mieć odpowiedź zanim napisaliśmy pierwszy controller.
Plug-and-play moduły
Każdy z 8 bounded contexts musi działać standalone albo podłączać się do zewnętrznego systemu klienta. Bez magii — porty i behavioral contracts.
Komunikacja bez bezpośrednich importów
Moduły nigdy nie importują się wzajemnie. Rozmawiają przez QueryBus (read), CommandBus (write) i Domain Events (reaktywność). Granice są wymuszone, nie umowne.
Modular monolith z planem na microservices
CQRS busy są warstwą transportu. Migracja do microservices = podmiana in-memory bus na HTTP/gRPC lub RabbitMQ. Bez przepisywania domeny.
Spójność danych
ACID wewnątrz agregatu (jeden moduł, jedna transakcja). Eventual consistency między modułami — Domain Events propagują zmiany asynchronicznie.
Pełna szczelność typów end-to-end
OpenAPI → Kubb → TanStack Query hooks. Zmiana w NestJS controllerze to compile-error w React, nie cichy runtime crash.
Domain Driven Design w praktyce
Agregaty z metodami, value objects (Money, GoodDimensions), domain errors. DDD jako rygor projektowy, nie tylko nazwa folderu.
Testowanie na trzech poziomach
Unit (domain logic, ~513 testów) + BDD handlers (CQRS z in-memory repos) + integration (Testcontainers z prawdziwym PostgreSQL).
Autoryzacja jako port
Moduły nie wiedzą o rolach. Pytają AuthorizationPort.canDo(action, user). Migracja na Keycloak / Auth0 = wymiana jednej implementacji.
Integracje z systemami klienta
Fakturownia.pl, zewnętrzny WMS, ERP, HR, TMS — każda integracja za portem z behavioral contractem. Adapter piszemy gdy znamy realne API.
CLI dla operatorów
nest-commander dzieli CQRS busy z HTTP — jedna logika, dwa punkty wejścia. Operator robi przez terminal to samo, co użytkownik przez UI.
ESM-native TypeScript 6
Cały stack ESM, nodenext resolution, brak CJS-only zależności. Stack przygotowany na kolejne 5 lat zamiast dług technologiczny od pierwszego dnia.
Multi-language UI od pierwszego dnia
Inlang Paraglide — kompilowane tłumaczenia, type-safe klucze, zero runtime overhead. PL i EN są obywatelami pierwszej kategorii, nie afterthought.
Testy nie są opcjonalnym work-follow-upem — są częścią feature'a. Bez nich nie ma "gotowe".
Macie podobnie złożony projekt? Pogadajmy.
Mapy, role, integracje z innymi zespołami i skala — wiemy, jak to ułożyć w spójny produkt mobilny.
Jak budujemy rdzeń produktu #
Trzy etapy, w których powstaje moduł MTCT. Ten sam wzorzec dla wszystkich ośmiu bounded contexts — przewidywalność jest cechą produktu, nie biurokracją.
Bounded context i kontrakty
Wydzielamy moduł z własnym językiem domeny, własnymi portami, własnymi tabelami (prefix `warehouse_`, `sales_`, …). Decydujemy, co publikujemy jako Domain Event, a co zostaje wewnętrzne. Brak fizycznych foreign keys między modułami — logiczne referencje (UUID).
Domain-first implementation
Agregaty z metodami, command handlers, query handlers, domain events. Controllers to cienka warstwa HTTP, ORM to detal infrastruktury. Każdy test jest pisany razem z featurem — nigdy po.
Behavioral contracts dla adapterów
Zamiast spekulatywnych adapterów do "jakichś" zewnętrznych systemów — definiujemy port i zestaw scenariuszy "co adapter musi umieć". Pierwszy klient = pierwszy adapter, ale interfejs i testy gotowe.
Pełna własność techniczna
Stack open source, ESM-native: PostgreSQL, NestJS, MikroORM, Vitest. Zero vendor lock-inów. Kod gotowy do audytu, do przekazania zespołowi klienta, do migracji na własną infrastrukturę. Architektura przygotowana na ekstrakcję do microservices bez przepisywania domeny.
Jak wygląda produkt w praktyce#
Dziewięć widoków z referencyjnej instancji MTCT — od centrum operacyjnego po panel konektorów. W realnym wdrożeniu układ, kolor i zakres modułów dopasowujemy do brandingu i potrzeb klienta.
Ekrany pochodzą z referencyjnej instancji MTCT z seedowanymi danymi demo. W realnym wdrożeniu kolor, układ i zakres modułów dopasowujemy do brandingu i potrzeb klienta.
Co jest w pudełku#
Liczby z aktualnej wersji rdzenia MTCT. Każda z nich to konsekwencja konkretnej decyzji architektonicznej, nie efekt uboczny.
Trzy decyzje, które definiują produkt#
Plug-and-play nie bierze się z marketingu. Bierze się z trzech konkretnych decyzji architektonicznych, podjętych zanim napisaliśmy pierwszy moduł. Każda ma swój ADR — z uzasadnieniem, konsekwencjami i odrzuconymi alternatywami.
CQRS Bus jako jedyny kontrakt między bounded contexts
Modułowy monolit, w którym moduły importują się wzajemnie, to monolit udający coś więcej. Pierwszy lazy import łamie izolację, drugi tworzy cykliczną zależność, trzeci sprawia, że "wymienialny moduł" staje się fikcją.
W MTCT moduły nigdy nie importują się wzajemnie. Rozmawiają wyłącznie przez trzy magistrale: QueryBus (read między modułami — np. spedycja pyta CRM o godziny dostępności klienta), CommandBus (write — wymuszenie zmiany w innym module), EventBus (reaktywność — sprzedaż publikuje OrderPlaced, magazyn rezerwuje towar, spedycja dodaje do planowania).
Konsekwencja: migracja na microservices to wymiana implementacji magistrali, nie przepisywanie domeny. In-memory bus → HTTP/gRPC dla read, RabbitMQ/Kafka dla eventów. Domena, handlery, agregaty zostają bez zmian. To nie obietnica — to architektoniczna gwarancja, wymuszona zakazem direct imports.
Standalone-first — adapter piszemy gdy znamy realne API
Frameworki integracyjne kuszą "uniwersalnymi adapterami" do popularnych systemów — WMS, ERP, fakturowni. Brzmi pragmatycznie, ale w praktyce: każda taka integracja oparta jest na zgadywaniu, jak API "powinno" wyglądać. Pierwszy realny klient z systemem v3 zamiast v4 = przepisanie od zera.
W MTCT każdy zewnętrzny system jest za portem (np. InvoicePort, WarehouseSyncPort). Zanim mamy klienta, definiujemy tylko interfejs i behavioral test contract — zestaw scenariuszy "co adapter musi umieć". Sam adapter piszemy dopiero, kiedy znamy konkretne API klienta.
Konsekwencja: zero spekulatywnego kodu, zero martwych integracji. Rdzeń jest gotowy, adaptery dopisujemy per-wdrożenie — i każdy z nich od pierwszego dnia jest precyzyjnym dopasowaniem do realnego API, nie kompromisem.
Autoryzacja jako port — moduły nie wiedzą o rolach
Klienci mają różne wymagania dotyczące autoryzacji. Mały zakład wystarczy mieć rolami zaszytymi w bazie. Średnia firma chce LDAP. Korporacja wymaga SSO przez Keycloak albo Azure AD. Jeśli logika autoryzacji jest rozsiana po modułach, każdy z tych wariantów to przepisanie ośmiu bounded contexts.
W MTCT moduły nigdy nie sprawdzają ról bezpośrednio. Pytają AuthorizationPort.canDo(action, user, context). Co więcej — moduł System (tożsamość: email, imię, role) jest fizycznie oddzielony od modułu Auth (credentials: passwordHash, refresh tokens). To pozwala wymienić jedno bez ruszania drugiego.
Konsekwencja: migracja z lokalnego RBAC na Keycloak = napisanie jednego adaptera KeycloakAuthorization. Reszta produktu nie wie, że coś się zmieniło. To samo dla Auth0, własnego OIDC, czy dowolnego nowego rozwiązania, które wymyśli klient.
Tech stack#
Wybór bibliotek wokół trzech decyzji powyżej. Standardowe, dobrze utrzymywane, open source, ESM-native. Bez ryzyka osierocenia, bez vendor lock-inów.
JĘZYK I RUNTIME
FRAMEWORK BACKEND
ORM I BAZA
WALIDACJA
TESTOWANIE
AUTH I BEZPIECZEŃSTWO
FRONTEND
API I KLIENT
Środowiska i ścieżki wdrożenia#
Cztery środowiska — od lokalnego dev po single-tenant produkcyjny u klienta. Im bliżej klienta, tym sztywniejsze kryteria i większy udział customizacji.
Chcecie podobne wyniki u siebie?
Wstępna analiza i wycena w 48h. Bez zobowiązań — pierwszy krok zawsze po naszej stronie.
Plug-and-play moduły — port + trzy implementacje #
Każdy moduł działa solo. Albo podłącza się do tego, co klient już ma.
Plug-and-play brzmi jak slogan. Pod spodem to bardzo konkretne ograniczenie: moduł nie może wiedzieć, kto dostarcza implementację. Nie może zakładać, że role są w bazie, nie może wołać konkretnego SDK, nie może instalować Keycloak-client jako runtime dependency.
Standardowa odpowiedź — "wszystko za interfejsem" — jest niewystarczająca. Interfejs bez behavioral contractu to obietnica bez egzekucji. Pierwszy adapter, który zwróci null zamiast pustej tablicy w nieprzewidzianym scenariuszu, łamie cały moduł — i nikt o tym nie wie do produkcji.
W MTCT obok każdego portu powstaje zestaw scenariuszy behawioralnych — testy, które każdy adapter musi przejść, niezależnie od backendu (fake, lokalne RBAC, Keycloak, Auth0). Dzięki temu wymiana implementacji jest operacją mechaniczną, nie research projektem. Port definiuje "co" — contract testy definiują "jak" — adapter dostarcza "czym".
// rdzeń: moduły rozmawiają z portem, nie z implementacją interface AuthorizationPort { canDo(action: Action, user: UserCtx): Promise<boolean>; } // trzy wymienne implementacje, jeden behavioral contract: class FakeAuthorization implements AuthorizationPort { /* dla testów */ } class RoleBasedAuthorization implements AuthorizationPort { /* lokalne RBAC */ } class KeycloakAuthorization implements AuthorizationPort { /* klient z SSO */ }
Port definiuje "co". Contract test definiuje "jak". Adapter dostarcza "czym". Każdą z tych warstw można wymienić niezależnie.