The product and its market context#
A typical customer in the production-and-distribution sector already uses Fakturownia for invoices, has their own WMS from a shelving vendor, imports CSV from a carrier's TMS, runs HR in Excel. What's missing is a layer that ties it all together and provides one operational view — who produces what, where it sits, where it's going, who paid for it. MTCT is that layer. With a full native stack available for companies starting from zero.
Deliver a modular B2B platform for supply-chain management — one where any module can be detached and wired into the customer's existing system. Instead of forcing a "rip and replace", MTCT connects what the customer already has — Fakturownia for invoices, external WMS, ERP, HR — and fills the gaps with native modules where they're missing. Architecture: hexagonal + CQRS + Domain Events, every external integration behind a port, every module tested on three levels (unit, BDD, integration with Testcontainers).
What we had to solve#
A multi-modular platform makes sense only when its modules are genuinely swappable — not when "we say they are". That required early, disciplining architectural decisions. Each of the twelve items below is a concrete challenge we had to answer before writing the first controller.
Plug-and-play modules
Each of the 8 bounded contexts has to run standalone or connect to a customer's external system. No magic — ports and behavioral contracts.
Communication without direct imports
Modules never import each other. They talk through QueryBus (read), CommandBus (write) and Domain Events (reactivity). Boundaries are enforced, not agreed upon.
Modular monolith with a microservices plan
CQRS buses are a transport layer. Microservices migration = swap the in-memory bus for HTTP/gRPC or RabbitMQ. No domain rewrite.
Data consistency
ACID inside an aggregate (one module, one transaction). Eventual consistency between modules — Domain Events propagate changes asynchronously.
Full end-to-end type safety
OpenAPI → Kubb → TanStack Query hooks. A change in a NestJS controller becomes a React compile error, not a silent runtime crash.
Domain Driven Design in practice
Aggregates with methods, value objects (Money, GoodDimensions), domain errors. DDD as a design discipline, not just a folder name.
Testing on three levels
Unit (domain logic, ~513 tests) + BDD handlers (CQRS with in-memory repos) + integration (Testcontainers with real PostgreSQL).
Authorization as a port
Modules don't know about roles. They ask AuthorizationPort.canDo(action, user). Migrating to Keycloak / Auth0 = swap one implementation.
Integrations with customer systems
Fakturownia.pl, external WMS, ERP, HR, TMS — every integration behind a port with a behavioral contract. The adapter is written when we know the real API.
CLI for operators
nest-commander shares CQRS buses with HTTP — one logic, two entry points. The operator does via terminal what the user does via UI.
ESM-native TypeScript 6
Entire stack on ESM, nodenext resolution, no CJS-only dependencies. A stack ready for the next 5 years instead of tech debt from day one.
Multi-language UI from day one
Inlang Paraglide — compiled translations, type-safe keys, zero runtime overhead. PL and EN are first-class citizens, not an afterthought.
Tests are not optional follow-up work — they are part of the feature. Without them there's no "done".
Got a similarly complex project? Let's talk.
Maps, roles, cross-team integrations and scale — we know how to wrap it all into one coherent mobile product.
How we build the product core #
Three stages in which an MTCT module is built. The same pattern for all eight bounded contexts — predictability is a product feature, not bureaucracy.
Bounded context and contracts
We carve out the module with its own domain language, its own ports, its own tables (prefix `warehouse_`, `sales_`, …). We decide what we publish as a Domain Event and what stays internal. No physical foreign keys between modules — logical references (UUID).
Domain-first implementation
Aggregates with methods, command handlers, query handlers, domain events. Controllers are a thin HTTP layer, the ORM is an infrastructure detail. Every test is written alongside the feature — never after.
Behavioral contracts for adapters
Instead of speculative adapters for "some" external systems — we define the port and a set of scenarios "what the adapter must handle". The first customer = the first adapter, but the interface and tests are ready.
Full technical ownership
An open source, ESM-native stack: PostgreSQL, NestJS, MikroORM, Vitest. Zero vendor lock-ins. Code ready for audit, for handover to the customer's team, for migration to their own infrastructure. Architecture prepared for extraction to microservices without rewriting the domain.
What the product looks like#
Nine views from the reference MTCT instance — from the operations dashboard to the connectors panel. In a real deployment we adapt the layout, color and module scope to the customer's brand and needs.
Screens come from the reference instance of MTCT with seeded demo data. In a real deployment we adapt the colors, layout and module scope to the customer's brand and needs.
What's in the box#
Numbers from the current MTCT core. Each is a consequence of a specific architectural decision, not a side effect.
Three decisions that define the product#
Plug-and-play doesn't come from marketing. It comes from three concrete architectural decisions made before we wrote the first module. Each has its own ADR — with rationale, consequences and rejected alternatives.
CQRS Bus as the only contract between bounded contexts
A modular monolith where modules import each other is a monolith pretending to be more. The first lazy import breaks isolation, the second creates a cyclic dependency, the third turns "swappable module" into fiction.
In MTCT, modules never import each other. They communicate only through three buses: QueryBus (cross-module reads — e.g. freight asking CRM for the customer's opening hours), CommandBus (writes — forcing a change in another module), EventBus (reactivity — sales publishes OrderPlaced, warehouse reserves goods, freight queues for planning).
Consequence: migration to microservices is a bus implementation swap, not a domain rewrite. In-memory bus → HTTP/gRPC for reads, RabbitMQ/Kafka for events. Domain, handlers, aggregates stay unchanged. That's not a promise — it's an architectural guarantee enforced by the ban on direct imports.
Standalone-first — adapters are written when the real API is known
Integration frameworks tempt with "universal adapters" for popular systems — WMS, ERP, invoicing. It sounds pragmatic, but in practice: every such integration is based on guessing what the API "should" look like. The first real customer running v3 instead of v4 = a rewrite from scratch.
In MTCT every external system sits behind a port (e.g. InvoicePort, WarehouseSyncPort). Before we have a customer, we define only the interface and a behavioral test contract — a set of scenarios "what the adapter must handle". The adapter itself is written once we know the customer's actual API.
Consequence: zero speculative code, zero dead integrations. The core is ready, adapters are written per deployment — and each one, from day one, is a precise fit to the real API, not a compromise.
Authorization as a port — modules don't know about roles
Customers have different authorization needs. A small plant is fine with roles in the database. A mid-size company wants LDAP. A corporation requires SSO via Keycloak or Azure AD. If authorization logic is scattered across modules, every variant is a rewrite of eight bounded contexts.
In MTCT, modules never check roles directly. They ask AuthorizationPort.canDo(action, user, context). On top of that — the System module (identity: email, name, roles) is physically separated from the Auth module (credentials: passwordHash, refresh tokens). That lets us swap one without touching the other.
Consequence: migrating from local RBAC to Keycloak = writing one KeycloakAuthorization adapter. The rest of the product doesn't notice. Same for Auth0, a custom OIDC, or whatever new solution the customer wants.
Tech stack#
Library choices around the three decisions above. Standard, well-maintained, open source, ESM-native. No abandonment risk, no vendor lock-ins.
LANGUAGE & RUNTIME
BACKEND FRAMEWORK
ORM & DATABASE
VALIDATION
TESTING
AUTH & SECURITY
FRONTEND
API & CLIENT
Environments and deployment paths#
Four environments — from local dev to single-tenant production at the customer. The closer to the customer, the stricter the criteria and the bigger the share of customization.
Want similar outcomes at your company?
Initial analysis and estimate within 48h. No obligations — the first step is always on us.
Plug-and-play modules — a port and three implementations #
Every module runs solo. Or connects to whatever the customer already has.
Plug-and-play sounds like a slogan. Underneath, it's a very concrete constraint: a module must not know who provides the implementation. It can't assume roles live in the database, it can't call a specific SDK, it can't install the Keycloak client as a runtime dependency.
The standard answer — "everything behind an interface" — is not enough. An interface without a behavioral contract is a promise without enforcement. The first adapter that returns null instead of an empty array in an unexpected scenario breaks the whole module — and nobody knows until production.
In MTCT, every port comes with a set of behavioral scenarios — tests that every adapter must pass, regardless of backend (fake, local RBAC, Keycloak, Auth0). That makes swapping implementations a mechanical operation, not a research project. The port defines "what", contract tests define "how", and the adapter delivers "with what".
// 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 */ }
The port defines "what". The contract test defines "how". The adapter delivers "with what". Each of those layers can be swapped independently.