/en/ case-studies / More Than Control Tower
// Case study · Product / Supply Chain Platform

More Than Control Tower — a modular platform for companies producing and distributing goods

A ready supply-chain platform — adapted to each customer's plant. 8 bounded contexts (warehouse, sales, freight, CRM, HR, accounting, system, auth) designed as plug-and-play — each runs standalone or connects to whatever the customer already has (Fakturownia, external WMS, ERP, HR). 26 documented architectural decisions, 600+ tests, ESM-native stack: TypeScript 6 + NestJS 11 + PostgreSQL 18.

Client · Adapted per deployment
Engagement · Product + customization
Role · Full product ownership
Sector · Supply Chain / B2B
8
Bounded contexts
26
Documented decisions (ADR)
600+
Tests (unit + BDD + integration)
0
Vendor lock-ins
01 · Context

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.

Target
SMB production + distribution
companies that produce and deliver goods
Contract model
Product + customization per project
Deployment model
On-prem or managed · single-tenant
Modularity
8 bounded contexts · each swappable
Ownership
philosopht — full roadmap control
Product status
Core ready · adapters per customer
Product goal

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).

02 · Challenges

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".
P
philosopht
MTCT engineering rule

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.

03 · Approach

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.

01

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).

02

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.

03

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.

04 · Screens

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.

05 · Results

What's in the box#

Numbers from the current MTCT core. Each is a consequence of a specific architectural decision, not a side effect.

8
bounded contexts (warehouse, sales, freight, CRM, ERP, HR, system, auth)
26
documented ADRs — each with consequences and alternatives
600+
tests in total (~513 unit + BDD handlers + integration with Testcontainers)
100%
type-safe from OpenAPI to React TanStack Query hooks (Kubb)
0
vendor lock-ins — open source stack, every module swappable
3.8×
Vitest faster than Jest — measured during the ADR-011 migration
Got a similar project on the horizon?See how we led other complex deployments with many users and roles.
Other case studies
From here — technical deep dive
Below — architecture, stack and environments: three technical decisions that shape the product the most, plus a full breakdown of libraries and deployment paths.
06 · Architecture and technical approach

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.

Decision 01 · Inter-module communication

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.

Decision 02 · Adapters to external systems

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.

Decision 03 · Authorization and identity

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

TypeScript 6Node.js 22ESM nativenodenext resolution

BACKEND FRAMEWORK

NestJS 11@nestjs/cqrs@nestjs/event-emitternest-commander

ORM & DATABASE

MikroORM 7PostgreSQL 18schema-first defineEntitymigrations CLI

VALIDATION

Zod 4class-validatorruntime + compile-time

TESTING

Vitest 4@testcontainers/postgresqlsupertestBDD handlers

AUTH & SECURITY

JWT httpOnly cookiesArgon2Authorization portrefresh token rotation

FRONTEND

ViteReactTanStack QueryTanStack FormInlang ParaglideKy

API & CLIENT

OpenAPI · SwaggerKubb (codegen)TanStack Query hooksshared-types pkg

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.

// release.pipeline target: prod
// release.pipeline 01 · Dev Development → dev client→ hot reload→ mocked data // audience philosopht team 02 · Pre-test Pre-test → fresh API snapshot→ internal smoke test // audience SmallGIS testers 03 · TestFlight TestFlight → TestFlight + Android→ real-field testing // audience hunters · PZŁ clubs 04 · Prod Production → target environment→ release ← all stages // audience all PZŁ members sharpening entry criteria →

Want similar outcomes at your company?

Initial analysis and estimate within 48h. No obligations — the first step is always on us.

07 · Dev story

Plug-and-play modules — a port and three implementations #

Engineering problem · Authorization

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.
P
philosopht
Plug-and-play principle in MTCT
Table of contents
  1. 01 Project context
  2. 02 Challenges
  3. 03 Approach
  4. 04 App screens
  5. 05 Results
  6. 06 Architecture & tech
  7. 07 Dev story
  8. Contact
Ready when you are

Tell us what you need.

Have an idea for an app or need tech support? Write to us — we'll prepare an initial analysis and estimate within 48h.

Write to us
[email protected]
Office
philosopht Dawid Michota
ul. Świętokrzyska 41A
26-001 Wola Kopcowa, Poland
NIP 6573002241
Free consultation