We're taking apart a transport management system — a TMS. Orders arrive from SAP, a driver and vehicle get matched against requirements, trips run through waypoints with time windows, checklists at each stop, handover reports with damage records. On top: a GPS stream into partitioned tables, trip and place tracking, a driver mobile app, and a public zone where customers watch their delivery. Fifteen modules, three nodes behind a load balancer, RabbitMQ and Kafka three nodes each, Redis, PostgreSQL, a separate identity server.
It was built because buying the TMS as a service from outside vendors got too risky — building it in-house was a way to get control of a core process back. And it was built fast: the system is sixteen months old, first commit April 2025.
In part one I took this system apart line by line and priced its infrastructure layer on WSO2 Micro Integrator and Entity Framework Core. It came to 3,700–6,800 hours one-off and 750–1,400 per year — 2.2–4 person-years up front and 0.45–0.85 of a permanent headcount annually, before a single line of business logic.
This post is what happens next. The system is leaving that state: the integration bus, the identity server and the ORM are all being removed.
I'll start with the module that has already moved — a line-by-line before-and-after. The headline result of that comparison was the opposite of what you expect from a migration post: there is now almost twice as much code. Why that's correct is the first half.
The second half is the other three layers, where the numbers run into person-years rather than hours.
Disclosure. I'm the architect and developer of the backend under analysis, and the author of the ecosystem it relies on. So this isn't a critique of someone else's decisions — it's my own bill. Which is also why measurements are kept apart from conclusions and numbers are broken out line by line: argue with the lines.
Scope: backend only. Web frontend is a separate team, mobile is a separate developer. Neither is in these numbers.
The team was complete: a systems analyst analysing requirements, a QA engineer verifying the work, a project manager running budget, deadlines, priorities, work order and risk. Worth remembering as you read — the accumulated infrastructure can't be explained away by "they had no process." And every hour quoted is development only.
Part of the redb / redb.Route series — recent posts first:
The timeline that explains everything else
Before counting anything, look at the dates. I pulled them out of the repository history.
| When | What appeared |
|---|---|
| April 2025, day 1 | first commit |
| April 2025, day 2 | ORM with migrations |
| April 2025, day 8 | in-house routing engine |
| June 2025, month 3 | in-house module container |
| June 2026, month 15 | first module on the new stack, no bus |
| July 2026, month 16 | current state |
Three observations, each worth stating.
The routing engine appeared on day eight. Not in year three after "hitting the limits of the platform" — immediately.
You do not write a 27,000-line routing framework in eight days. You bring it with you, finished. Which is where I should say plainly who and why: the architecture was designed before the project started, together with the tool that implements it. The engine wasn't shopped for on the market or picked from a comparison table.
What follows from that matters for reading the rest: the system is not accidental. Not one of the layers I take apart showed up by drift — each was chosen deliberately, and there's someone to answer for each choice. Everything called a cost below is my own bill, not a critique over someone's shoulder.
The bus stayed anyway. Both integration layers stood side by side from month one and still do. Not "bus first, then ours replacing it" — two runtimes simultaneously, all sixteen months.
Dismantling started in month fifteen. Not after five years of ossification, but almost as soon as the cost of what was chosen at the start became clear.
Which gives the conclusion I want at the top rather than the bottom. The infrastructure layer in this system is not an inheritance. It was created in sixteen months by decisions taken in the first week.
And that changes who this post is for. If you're reading someone else's tech-debt post thinking "my project is new, I don't have this" — here's a system sixteen months old with 105,000 lines of infrastructure in it. Tech debt doesn't accrue with time. It accrues with the stack you pick on day one.
First, how we count — because we count dishonestly
Before comparing anything, deal with the bias built into the counting method. It's in part one, it's in most migration posts, and I fell into it myself.
When we say "integration bus," we count our artefacts: 206 XML files, 4,099 lines of Java. We never count the platform's own internals — and that's a large Java product: runtime, mediation engine, dozens of transports, a management console. Hundreds of thousands of lines that somebody wrote, debugs and versions. Not one line of it enters the estimate.
When we say "identity server," we count 227 tables, because tables are visible in a database. The code serving those tables we don't count, though it exists and somebody maintains it.
And when it's our own engine, we count everything: 27,529 lines of routing engine, 11,117 lines of module container, and we invoice 1,940–3,400 hours for it.
The difference isn't economics. The difference is what happens to live in our repository. Someone else's internals are invisible, so they feel free. Ours are visible, so they feel like a line item.
This bias runs against the in-house stack, not for it
Let me state the counter-intuitive part directly: this way of counting inflates the price of your own solution and deflates the price of the bought one. Not the other way round.
Counted symmetrically, there are exactly two honest options.
Option one — count nobody's internals. Then the "free" bus is an external dependency you added. And an engine pulled in as a package is an external dependency you added. Both are zero in the budget. What you compare is what you wrote on top: 861 lines of XML plus 4,099 lines of Java, against 212 lines of route declarations.
Option two — count everyone's internals. Then next to 38,646 lines of engine and container stand the hundreds of thousands in the bus platform and the identity server. And the in-house engine turns out to be an order of magnitude smaller than what it replaces.
Both options give the same answer, and it's the opposite of the intuitive one. The asymmetric count — the one we apply by default — is the only one under which the in-house solution looks more expensive.
The part that isn't about line counts
There's one property that makes those two options unequal, though, and it's decisive.
The engine's internals never enter your repository at all.
It ships the way any normal platform ships: libraries as NuGet packages, the runtime as a container image or a signed standalone archive, releases on GitHub. You add a dependency and write your module. No sources in your tree, no building them in your pipeline, no upgrading them by hand.
Which is why those 38,646 lines are, in the new arrangement, not a cost line but zero lines in the repository. Exactly the sense in which a ready-made bus's internals were always zero. It's just symmetric now.
And on top of that there's something the vendor platform doesn't offer: if you need to go inside, you can. Reference the sources instead of the package and step through with a debugger all the way down. Not "fork it and live with the fork" — look, fix, offer it back.
The vendor platform gives you no such choice. There's a binary, and exactly one way in: fork it. Which is what happened in this project — the transport connector misbehaved, waiting for a vendor fix wasn't viable, and 2,429 lines of someone else's Java landed in the repository permanently, off the upgrade path.
That's the real difference, not the line count. By default you carry nothing — and if you need to, the door inside is open. A fork isn't a benefit of an open platform; it's the price of having no sane way in. Paid once, then again at every upgrade.
What goes and what stays
The layout from part one. The system had four infrastructure layers:
| Layer | Cost | Fate |
|---|---|---|
| Integration bus: 195 artefacts of XML + Java | 904–1,708 h | removed |
| ORM: 1,500 lines of configuration, 54 models, repositories | 440–820 h | removed |
| Identity server: 4 databases, 227 service tables | part of 450–850 h | removed |
| Own routing engine and module container | 1,940–3,400 h | stays, becomes the foundation |
Three of four get dismantled. What remains is the one that cost more than the other three combined.
That's probably the main conclusion of both posts, and it isn't about technology. The cheap-at-the-door things left. The expensive-at-the-door thing stayed and keeps earning.
Where we started: the public zone
The first thing to move was the customer-facing zone — a public area pushed outside the company perimeter into the cloud.
INSIDE THE COMPANY CLOUD (public zone)
────────────────── ───────────────────
backend ──── writes ────────► its own RabbitMQ
(inbound only) │
▼
its own PostgreSQL
its own Redis
│
▼
HTTP API → customer site
The key property: there are no return connections. The company backend reaches into the zone and writes data. From inside the zone outward is forbidden. Own database, own broker, own cache; the zone is self-sufficient.
Why start here becomes obvious if you think about risk. The boundary is already drawn architecturally, not sketched on a diagram. The zone can be replaced wholesale without touching anything inside the company: outward it talks only to the customer site, inward it doesn't call at all. The cost of a mistake is capped at one zone.
If you're planning a similar move — look for the same thing in your own system. Not the most important part, not the hardest. The most severed one.
One module, before and after
Before
Ten synapse-XML artefacts plus a Java mediator:
| Artefact | Lines |
|---|---|
| Client API | 345 |
| Driver position proxy service | 312 |
| Delivery info proxy service | 147 |
| Error handling sequence | 57 |
| Redis configuration sequence | 23 |
| Token validation sequence | 18 |
| Two Redis connection entries | 23 |
| RabbitMQ endpoint | 5 |
| Mediator library descriptor | 6 |
| XML total | 936 |
| Java token validation mediator | 183 |
| Total across two languages | 1,119 |
After
One C# module, eighteen files:
| File | Lines |
|---|---|
| Client API routes | 407 |
| Driver position route | 343 |
| Delivery info route | 289 |
| Token validator | 238 |
| Configuration loader | 172 |
| Module entry point | 120 |
| Exception handler | 83 |
| API error model | 46 |
| Scheduled partition maintenance | 42 |
| Six typed configuration files | 168 |
| Two models and a validation result | 33 |
| Total | 1,941 |
1,119 → 1,941. Growth of 1.7×.
This is usually where a migration post ends, because the number is inconvenient. Let's take it apart honestly — starting with method, because counting lines across languages is easy to rig in either direction.
How I counted
Three ways to count the same thing, all on the same code:
| Method | Before | After | Growth |
|---|---|---|---|
| Raw lines, build files excluded | 1,119 | 1,941 | ×1.73 |
| Blank lines and comments excluded | 921 | 1,330 | ×1.44 |
| Build files included | 1,582 | 1,971 | ×1.25 |
All three show growth, so it isn't an artefact of the method. I'll use the first row from here on, as the least flattering to the new code: if a conclusion survives that one, it survives the others.
The third row hides its own story. Packaging this module for the bus requires a 463-line build descriptor. The new module needs thirty. Fifteen times the size, in a file that does nothing but explain how to build everything else. I keep build files out of the main count to compare code with code — but dropping the fact entirely would be dishonest, because somebody maintains that too.
And the caveat without which the table is worthless: a line of XML and a line of C# are different units. Adding them into one metric is a rough volume approximation, not a complexity measurement. Which is why what follows takes apart the composition, not the total.
Why these two things are comparable at all
Let me justify the comparison itself, or the rest is a matter of taste.
Neither side is an arbitrary invention. Both implement the same industry catalogue — the one described in Gregor Hohpe and Bobby Woolf's Enterprise Integration Patterns, which formalises 65 messaging patterns: routers, splitters, aggregators, enrichers, translators and the rest. That's the industry's shared vocabulary. Apache Camel is built on it. So is WSO2 MI. So is the engine in this post.
Which means this isn't "someone's product versus my hobby project" — it's two implementations of one specification. The same pattern is a mediator tag on one side and a method call on the other. That's exactly why comparing declarations line for line is meaningful: both describe the same set of concepts.
Since we're here, let me check my own coverage claim against the code rather than the project page. And note the counting mistake I nearly made myself: the book is not just about routing. More than half the catalogue is channels, endpoints and message construction — implemented by transports and the kernel, not by the routing language.
| Group in the book | What exists in the engine |
|---|---|
| Routing | Content-Based Router (Choice/When/Otherwise), Message Filter, Dynamic Router, Recipient List, Splitter, Aggregator, Resequencer, Scatter-Gather, Multicast |
| Transformation | Message Translator, Content Enricher (Enrich/PollEnrich), Content Filter, Normalizer, Envelope Wrapper (Marshal/Unmarshal), Claim Check, Datatype Channel (OfType) |
| Channels |
Channel Adapter — 27 transports, Point-to-Point Channel (direct, vm, direct-vm, seda, queues), Publish-Subscribe Channel, Message Bus, Messaging Bridge, Dead Letter Channel, Invalid Message Channel, Guaranteed Delivery |
| Endpoints | Polling Consumer (timer, cron, file and FTP polling), Event-Driven Consumer, Messaging Gateway (ProducerTemplate), Competing Consumers, Message Dispatcher (LoadBalance), Selective Consumer, Idempotent Receiver, Transactional Client, Service Activator |
| Message construction | Request-Reply (InOut), Return Address (ReplyTo), Correlation Identifier, Message Expiration (TTL) |
| Management | Wire Tap, Message Store (Replayable), Message History (tracing), Control Bus, Process Manager (Saga) |
That's around forty catalogue patterns, and not by stretching — each is backed by a DSL method, a transport, or a kernel primitive.
Channel Adapter deserves singling out, because the book treats it as central and real life proves it: connecting to things is the most laborious part of any integration. Twenty-seven transports: brokers (RabbitMQ, Kafka, AMQP, IBM MQ, Azure Service Bus, SQS, MQTT), protocols (HTTP, gRPC, TCP, WebSocket, SignalR), files (File, FTP, SFTP, S3), data (SQL, Redis, Elasticsearch), plus mail, LDAP, Telegram, a scheduler and process execution.
And second, the in-process channels where no broker is involved at all.
There are four, solving different problems. direct — a synchronous call inside a route. direct-vm — a synchronous call between different contexts in the same process; that's what module-to-identity-server traffic runs on, more on which below. vm — asynchronous handoff between contexts. And seda — an in-memory queue with a bounded size, N competing consumers, an enqueue timeout and queue-depth counters.
The value of that last one is that classic stage decoupling costs you no broker. Need to separate fast intake from slow processing? Put an in-memory queue between them and set a worker count. You don't stand up RabbitMQ to get an async stage inside one process — and the bounded queue gives you natural backpressure instead of unbounded growth.
In the book's terms that's Point-to-Point Channel plus Competing Consumers, and it's a case of a catalogue pattern being closed by a kernel primitive rather than external infrastructure. In the "before" stack the same decoupling meant a broker queue — with the connection, topology, monitoring and network hop that implies.
Every pattern is visible in telemetry
Here's what comes with that catalogue and usually gets lost in EIP discussions. The patterns aren't merely implemented — each one is instrumented separately, and not with home-grown statistics in a private format but with standard OpenTelemetry.
Metrics are defined per pattern, not per engine:
| Pattern | What's measured |
|---|---|
| Aggregator | groups completed, groups in flight, groups timed out |
| Splitter | parts produced |
| Filter | messages dropped |
| Idempotent Receiver | passed, rejected as duplicates |
| Multicast | branch count, failed branches |
| Recipient List | recipients selected |
| Circuit Breaker | times tripped, calls rejected |
| Retry | attempts, successes after retry, exhaustions |
| Saga | completed, failed, compensated |
| Throttle / Timeout / Debounce | delayed, expired, discarded and flushed |
| Wire Tap | copies dispatched, copies failed |
| Dead Letter | messages dead-lettered |
Plus end-to-end: processed, failed, currently in flight, and duration histograms — for the whole exchange and for each route step.
Tracing isn't home-grown either. Spans come from a standard activity source, and attributes follow OpenTelemetry semantic conventions — messaging.system, messaging.destination.name, messaging.operation, http.method, db.system, file.system, with exceptions broken into exception.type / message / stacktrace. On top of those, its own: correlation id, exchange pattern, route, step, endpoint.
The practical consequence: Jaeger, Grafana or Tempo read this with no adapter — subscribe to the source and you're done. The module container knows about telemetry and wires it up itself, so "consumed from queue → enriched → hit the database → replied" shows up as one trace with every step and duration.
The difference from the "before" stack isn't the presence of logs — everybody has logs. The difference is that "which step is slow and why" is answered by a trace, rather than by correlating journal entries from two runtimes by timestamp.
Beyond the catalogue there are nine more patterns absent from the 2003 book but now de-facto standard in Camel: Circuit Breaker, Throttle, Debounce, Delay, Loop, Threads, Sample, Validate, TryCatch.
And the project is moving fast. Twelve releases in three months — from 1.0.4 in early May to 3.4.0 in late July. In that window: concurrency handoff as a first-class .Threads(N) pattern, replay checkpoints for failed exchanges, an Amazon queue connector, and a shared runtime layer where patching the framework is a file swap with no rebuild. The catalogue keeps closing; transports keep landing.
Worth saying plainly in a post about migrating off a mature platform: the engine's youth is a real factor in the decision, and pretending otherwise would be dishonest. The flip side is equally real — what sat in a vendor's tracker for years here closes in weeks, because the author is in the room and priorities come from operations rather than someone else's roadmap.
The correction that matters: I was comparing the wrong things
Now I have to hand back my own number, because as stated it misleads.
Adding "all the XML" to "all the C#" is wrong, because each stack has two distinct layers: the declarative route description, and the imperative logic a declaration can't express. Count them separately or the multiplier means nothing.
Layer one — the route declaration. On the bus that's synapse-XML; in the new module it's a block of the embedded language. Comparing like with like:
| Route declaration | XML | DSL | Density |
|---|---|---|---|
| Client API (three resources) | 345 | 72 | ×4.8 |
| Driver position | 312 | 52 | ×6.0 |
| Delivery info | 147 | 53 | ×2.8 |
| Error handling | 57 | 35 | ×1.6 |
| Total | 861 | 212 | ×4.1 |
861 lines of XML against 212 lines of DSL. On the layer where comparison is actually valid, the new stack is four times denser. And that layer — "declarativeness" — is precisely what ESB posts usually name as the ready-made bus's main advantage. It lost fourfold.
Layer two — imperative logic. Both stacks need it: parse the payload, compare statuses, build the response. The difference is where it lives.
On the bus there's nowhere for it but Java. Here's how much Java sits in the bus project overall, not in one module:
| Java in the bus project | Files | Lines |
|---|---|---|
| Forked transport connector — someone else's code, permanently maintained | 12 | 2,429 |
| Own class mediators — what the XML couldn't express | 6 | 1,670 |
| Total | 18 | 4,099 |
Four thousand lines of Java hold up the "declarative" platform. Half a fork of someone else's connector; half in-house mediators written where XML ran out.
In the new module that layer didn't disappear — it stayed in the same file, in the same language as the declaration. That's the entire source of the headline growth: what used to be a separate project in a separate language with a separate build now sits next to the route.
Which restates the conclusion. Not "code grew 1.7× when leaving the ESB." Rather: the declaration shrank fourfold, and the imperative logic moved out of an invisible Java project into the file next to the route. The net increase is the price of that move from invisible to visible, and it's an honest price.
And second: the new code isn't written densely — deliberately
The new module is written in a plain, spread-out style, for the debugger. In practice that means:
- every route step is a named private method with a doc comment — extract the data, resolve saved state from cache, resolve from the database, compare statuses, build the response. Not because there's no alternative, but so each step can take a breakpoint and show its state;
- exchange property names are hoisted into thirteen constants instead of inline literals;
- retry parameters are named constants of their own;
- each step carries a numbered comment.
And crucially: the framework offers something this code didn't use. It has an expression subsystem with JSONPath, compiled and typed variants. The module extracts data by hand instead: parse the JSON, reach for fields one at a time, try/catch around it, scatter the results across six properties. Forty-odd lines where a handful of expressions would do.
How much tighter it could be, I haven't measured and won't quote. But several times over is realistic, and I'll assert that as the author of both sides of the comparison.
Now combine both corrections and the asymmetry becomes visible.
Synapse-XML has exactly one compression mechanism: move the logic into a Java mediator. That's how token validation became eighteen lines — not density, delegation. You cannot compress the XML without evicting logic from it; every mediation step is a tag.
The XML in this comparison sits at its floor — it gets no denser, and can only shrink by hiding. The new code sits above its floor by choice — for breakpoints, readability and line-by-line verification against the original.
So ×1.73 is not the price of the stack. It's the price of the style chosen for a one-to-one port. The same result on the same framework could have been several times denser — and would have lost exactly what the move was for.
Where the growth actually went
| Before | Lines | After | Lines |
|---|---|---|---|
| Client API (XML) | 345 | Client API routes | 407 |
| Driver position (XML) | 312 | Driver position route | 343 |
| Delivery info (XML) | 147 | Delivery info route | 289 |
| Error handling (XML) | 57 | Exception handler | 83 |
| Token validation (XML + Java) | 201 | Token validator + result | 255 |
| Redis and RabbitMQ connections (XML) | 51 | Typed configuration | 168 |
| Mediator library descriptor (XML) | 6 | — | 0 |
| — | 0 | API error model, two domain models | 62 |
| — | 0 | Scheduled partition maintenance | 42 |
| — | 0 | Configuration loader, entry point | 292 |
| Total | 1,119 | 1,941 |
Three sources of growth, none of them verbosity.
One: things that didn't exist before
Monthly partition maintenance on a schedule — forty-two lines that had no counterpart in the old version. Previously somebody else did it, or nobody did.
Configuration loader and module entry point — nearly three hundred lines. In the old arrangement the platform played that role: configuration arrived from its files, lifecycle was its business. Now it's explicit code you can read.
That's 334 lines of added, not rewritten. Subtract them and ×1.73 becomes ×1.44 — the exact figure you get counting without blanks and comments. Two independent methods landing on the same number is a decent check on both.
Two: configuration became typed
Fifty-one lines of XML connection descriptions became 168 lines of settings classes.
Sounds like a regression until you look at what you got. Before: factory names as strings, parameters mixed together, a typo in a parameter name discovered at startup at best and at first failure at worst. Now: typed classes, defaults, compile-time checking.
A hundred and seventeen extra lines is the price of a connection misconfiguration no longer being a production discovery.
Three: the hidden came out
This is the important one.
Token validation in the old version is eighteen lines of XML. Eighteen, because the actual work happened somewhere else: the XML only invoked a 183-line Java mediator. One piece of logic, two languages, two places to look for it.
Now it's 255 lines of C# in one file that opens in a debugger, takes tests, and turns up in a code search.
Same story with error handling and the delivery info route: what looked short in XML was short because of what had been moved out of it.
What actually changed
Three languages collapsed into one
Before: XML for routes, Java for what XML can't express, settings files for configuration. Three build pipelines, three debugging stories, three places to look for a cause.
After: one language, one pipeline, one debugger.
It's worth registering what that lost "line saving" actually meant. Eighteen lines of XML looked cheaper than two hundred lines of C# right up until somebody asked: where exactly does the validation happen? And answering required knowing that a Java mediator exists, where it lives, how it's wired, and who builds it.
Exactly one service is still our own
The new module has precisely one class with logic of its own — the token validator. The comment at the entry point explains why:
Only custom logic not covered by the DSL: the token validator. SQL, Redis and RabbitMQ run in the routes; there are no separate repositories.
Everything else — consuming from queues, writing to the database, cache access, the HTTP facade, partition maintenance — is expressed as routes. The HTTP facade is built with no controllers at all: three resources declared directly in routes.
The validator stayed custom not out of preference but out of necessity: the public zone cannot ask the company whether a key is valid — there are no return connections. Validation has to be local and self-contained.
Two details that say more than the numbers
The port is documented in a comment
The header of one new route says it outright: "one-to-one port of the XML proxy" — followed by the original's algorithm, step by step: what to pull from headers, what from the body, where to look up saved status, under what condition to update database and cache, what to return.
That's not documentation for its own sake. It's how you prove behaviour didn't drift: while the original algorithm is written down alongside, anyone can check.
Worth stealing for anyone rewriting something that works. Port one-to-one, pin the behaviour in a comment, improve later as a separate step. The temptation to "make it nice while we're here" is the main reason migrations fail.
The cache keys still remember the platform
Redis key names in the new module start with a prefix containing the name of the integration platform. The one that no longer exists in this zone.
It happened because the key format couldn't change — data had to stay compatible during the transition. The platform was switched off; its name stayed in the data structure.
Small, but telling: a migration doesn't end the day the old code is deleted. Traces linger in formats, names and schemas for a long time.
What the move cost
I was going to give a model with assumptions here, as in part one. I broke it into stages — study the original, port the routes, the token validator, configuration, testing — and got 193–335 hours, i.e. a week and a half to two weeks for the module.
Then I looked at the repository history.
| When | What |
|---|---|
| 30 June, 21:22 | first commit of the module |
| 1 July, 11:21 | second |
| 1 July, 12:25 | third |
| 7 and 9 July | two fixes from testing |
The port took a day. The evening of 30 June and the first half of the next day — after that, only refinement from test results, two commits over nine days.
My model was off by roughly twenty times. I'm leaving that in the post exactly as it happened, because the discrepancy is more interesting than the estimate: an expert estimate built on familiar yardsticks missed reality by more than an order of magnitude. Had I not gone into git, I'd have published plausible and entirely wrong numbers.
Why a day and not two weeks
Three reasons, and the first is one that migration posts don't yet discuss.
The port was done in a pair with a language model. And it worked not because "LLMs can write code now" but for a specific reason: the engine's DSL follows Apache Camel's idiom — the same catalogue patterns, the same from → process → to shape, the same URI endpoint addressing.
And language models know Camel very well. Twenty years in production, thousands of projects, mountains of examples, docs and forum questions — all of it in the training data. Which means following an industry standard had an unexpected side effect: the model already knows your API even though it has never seen it. It recognises the idiom.
That's worth absorbing if you design libraries today. The old argument for a familiar idiom was that developers onboard faster. There's now a second one, and it's heavier: the tool the developer writes with knows it too. Your own original idiom forfeits that — and the price of originality just went up.
I checked the reverse on the same task: synapse-XML with platform-specific extensions and artefacts referencing each other by name takes assistance noticeably worse. The format is narrow and there's little of it in the world.
Second reason — a one-to-one port. The original algorithm is known; there's nothing to invent. The task reduces to translating between two description languages, which is exactly what models do best.
Third — the same person wrote both sides. Both the source system and the target engine. That's an advantage nobody else has, and crediting the tool for what's explained by domain knowledge would be dishonest.
And deploying it — an hour by hand, fifteen minutes with a model
Second number in the same series, and it maps straight onto part one.
The "before" estimate has a line: "deploying and configuring the bus, clustering, training the team — 300–500 hours." That isn't padding: standing up a bus, clustering it, configuring the configuration stores and teaching a team to live with it is a couple of months of work.
There's something to compare against. Take the deployment of another system on the same stack: the topology is described in a single container compose file, and here's what comes up from it:
- three worker nodes in a cluster, coordinating through a shared database, one group name;
- a management web console across all three, reaching them with a service key;
- a search engine with its UI, and object storage;
- the application web frontend.
Plus the cross-cutting configuration: database connection string, queue factory parameters in the default context inherited by every module, authentication with signing and roles, secrets externalised to environment variables.
That file was assembled with a language model in about a quarter of an hour. By hand, without an assistant, around an hour: it's a ten-kilobyte file and it needs writing carefully.
Both numbers are honest and both are worth holding: an hour if you write it yourself, fifteen minutes paired with a model. Against three hundred hours the difference between them stops mattering, but claiming fifteen minutes as the general result would be sleight of hand.
The reason a model is effective here is exactly the reason it was effective on the routes: there is no proprietary deployment format. An ordinary container compose file plus configuration through environment variables on a predictable scheme. The model has seen thousands of those and writes them confidently.
Contrast with the alternative: the platform has its own deployment descriptor, its own build artefact, its own configuration wizards — and that a model won't write, because examples are scarce and the specifics are vendor-bound. There you're alone with the vendor's documentation, same as ten years ago.
And shipping the modules themselves is a file copy. Built packages go into a directory mounted into the container, which picks them up on the fly. No image rebuild, no process restart, no deploying the whole system for one integration.
The level isn't "development-grade" either. Everything that usually makes up those three hundred hours arrives pre-assembled:
- three operating modes — standalone process with no database, single node with storage, or a cluster with leader election and automatic context redistribution;
- observability — process and per-route metrics, ring-buffer logs, OpenTelemetry traces, optional metrics scraping;
- a watchdog — detects suspect and hung routes and can restart them itself;
- a scheduler — schema auto-created on first start, cluster-safe;
- security — signed API keys, roles, expiry, revocation, user binding;
- module isolation — each gets its own load context; dependencies don't collide;
- management — REST API, CLI and a web dashboard, in the box rather than "write your own."
Nothing here is assembled piecemeal. A module goes into a directory and the container picks it up live, without stopping its neighbours.
And separately about the dashboard, because it's usually the thing people pay a commercial bus for.
Eleven pages: overview, routes and single-route view, endpoints, cluster and node detail, scheduler, logs, audit, dead-letter queue, watchdog, access control.
And it's a control plane, not a viewer — a distinction worth stating.
An individual route can be stopped and started from the dashboard, leaving the others alone. A whole context too: stop, start, restart. A hung route can be force-stopped. So the answer to "turn off that integration, it's taking down the neighbouring system" is two clicks, not a redeploy and not a ticket to DevOps.
Next to each route are its own numbers: processed, currently in flight, throughput, history. Not one process-wide graph but per route, because the question is always "which integration is stuck," not "how is the service feeling."
The watchdog gets its own page: suspect and hung routes, automatic restart, toggled right there.
Cluster handling isn't just a node list. You can see who the current leader is and when each node last reported. A node can be drained gracefully: it finishes what it's holding, takes no new work, hands its locks to peers — then comes back. You can rebalance everything, or evict a node hard. Node detail shows CPU, memory, threads, GC with history, its contexts and its logs.
Also from the dashboard: failed exchanges can be inspected and replayed after a fix, a scheduled job fired immediately, and a node's effective configuration read with secrets redacted — without SSH.
Line that up against the real failure modes from part one: consumers that silently stopped reading a queue, a hung request-reply exchange, manual restarts after a cluster rebuild. Every one of them is precisely the case this dashboard exists for — and each was being handled by walking onto a node instead.
This is where the DevOps person-hours actually are. Not in shorter routes, but in a management layer that doesn't take weeks to stand up and isn't hand-written for the third time this decade.
The limits of that number
A day is the port, not the whole thing. Outside it: studying the original, done earlier and separately; testing and behaviour verification against the old zone; two fixes from test results. Full elapsed time from first commit to last is nine days.
And to repeat the third reason, because it's decisive: going from two weeks to one day is not a property of the tool but the sum of three circumstances, of which two are reproducible. If you have a one-to-one port and a framework with a standard idiom, the first two are available to you. Both sides of the system in one head, probably not.
What it saves per year
The saving isn't in lines, it's in what stops happening:
| What goes away | Hours/year |
|---|---|
| Maintaining XML artefacts for this zone | 15–25 |
| Diagnosing request-reply hangs on a shared channel | 20–40 |
| Manual restarts after cluster rebuilds | 10–20 |
| Maintaining the Java mediator: separate pipeline, separate competence | 15–30 |
| Total | 60–115 |
Payback measured against the fact rather than my failed estimate: a day of porting against 60–115 hours saved per year — the investment returns inside the first month.
What isn't in the calculation is what can't be counted: silent failures. A consumer that stopped reading and didn't say so. An exchange hung on a shared channel. A night-time restart after a routine upgrade. Part one covered those in detail — each costs more than any row in a table, and none can be estimated in advance.
The real reason for the move isn't hours. It's that failures became visible and diagnosable.
Two runtimes on one node
Only one module has moved so far. But something important is already visible if you look not at code but at where all this physically runs.
The cluster topology, per the system's architecture documentation: three nodes, and each of them runs the in-house module container and WSO2 MI side by side. Not "the bus lives in some separate zone" — two different runtimes shoulder to shoulder, on the same hardware, for the whole life of the project.
That's the real cost of a heterogeneous stack — not the abstract "two build pipelines" line from part one, but literally: each of three nodes needs two runtimes serviced with different tools from scratch.
- WSO2 MI is packaged into a Carbon Application (
.car) via Maven — its own build pipeline, its own artefact versioning, its own rollout process per node. - A module on the new container is an ordinary .NET assembly dropped into a container volume. Which modules load on a given node is one environment variable with wildcard patterns — no image repackaging, no separate release per module.
For a DevOps engineer running three nodes, that isn't "a bit more work" — it's a second full area of expertise alongside the first: Java, Maven, Carbon packaging, its own logs and metrics. And all of it is needed as long as a single node in the cluster still runs the bus, no matter how many modules have moved.
That's the key non-linearity of this transition's economics. Savings on DevOps competence don't scale with the number of migrated modules — they land in one step, when the last Carbon Application comes off the last node and the cluster stops being a two-runtime cluster. Until then you carry the second competence in full, not "half, since half the modules moved."
Layer two: the ORM whose migrations disappear
Analysis of the application layer produced this. All measured, not estimated:
| Count | |
|---|---|
DbSet declarations |
51 |
| Lines of model configuration in one method | ~1,500 |
| Files with eager-loading chains | 70 (40 + 30 nested) |
Files calling SaveChanges
|
40 |
| ORM models with navigation properties | 54 |
| SQL views assembling graphs into JSON | 5, of which one is used, four dead |
| Generic repository + specifications | yes |
| Migration files | 3 |
And the question all of it was counted for: what here genuinely requires an ORM?
By the code: two partitioned tables, five views and three raw SQL calls. Everything else is servicing the ORM itself.
Fifteen hundred lines of model description and a migration system exist to serve two partitioned entities and three queries. Partitions and raw SQL need no ORM — they work over a direct connection.
The five views that were a workaround
They assemble object graphs into JSON — doing on the database side what the ORM couldn't do acceptably. Four of five are used by nobody: dead code nobody dared delete because it's unclear who might be watching.
An object store assembles the graph in one call natively. The views don't get "rewritten" — they stop being necessary.
What losing migrations means — not for the developer, for the business
This is the part technical posts mention in passing, and it's the most expensive one.
A migration isn't a repository artefact. It's an operational event. Every migration against a live system means:
- a release window agreed with the business;
- a rollback plan, and an answer for what happens if rollback fails;
- someone with production database rights;
- three nodes where application order matters;
- a freeze on parallel schema changes in other branches until this one lands.
In an object store the schema derives from types: add a property to a class and it exists at the next initialisation. No migration file, no window, no rollback, no person with rights.
That isn't "more convenient for developers." That's an entire class of operational events disappearing from the company calendar — along with the approvals, the night windows, and the releases pushed because a migration didn't apply on the second node.
The measured size of what gets scrapped
I counted the data layer whole, separating human-written from tool-generated — they can't be mixed:
| What goes | Files | Lines |
|---|---|---|
| Data layer: 55 models, reference data, interceptors, services | 88 | 7,547 |
| Database provider: a 2,049-line context, key generator, migration bodies | 8 | 5,125 |
| Written by humans | 96 | 12,672 |
| Generated by tooling: model snapshots | 4 | 17,407 |
| Total leaving | 100 | 30,079 |
Thirty thousand lines. A hundred files. But the interesting part is the second-to-last row.
Generated model snapshots — 17,407 lines across four files, four thousand-odd each. Schema descriptions at each migration point plus the current snapshot. The tooling writes them itself.
Those files live in version control. They show up in pull requests. They conflict on branch merges — and nobody wants to resolve a conflict inside a four-thousand-line generated snapshot. Nobody reads them, everybody has to commit them.
Seventeen thousand lines no human wrote and no human reads, versioned, reviewed and conflicting like ordinary code. That's what "a migration system" physically means in a repository, on top of the night windows in production.
Plus 2,877 lines of hand-written migration bodies — those were written by hand, 2,748 of them in a single schema-creation file.
With a schema derived from types, neither exists. Not "there's less of them" — there are none.
Layer three: integrations collapse — but not on their own
Here's the causal link worth not losing. Integrations get simpler not because they were rewritten better. They get simpler because the ORM left them.
There are four integration modules: transforming data from SAP, persisting the result, outbound exchange via outbox, and a host application.
The first is pure transformation and never touches the database. The ORM never touched it, and it won't change at all.
All the bulk sits in the second. What it does today, verbatim from the code: look up an entity by external identifier, create or update a graph of related objects, manually synchronise collections, save changes. Once per entity type arriving from SAP — and each drags two to four related objects behind it.
What remains with an object store: save the object in one call. The graph goes as a whole.
| What the integration did | What it becomes |
|---|---|
| Lookup by external key → create/update graph → save changes | Query by external key → save object |
| Manual synchronisation of nested collections | Part of the graph; no separate code |
| Custom reference-data cache on a dictionary | Built-in list provider with TTL |
| Reading an external identifier via a separate no-tracking context | An ordinary query |
Why code shrinks here and grew in the bus module
This is the important reversal, and it explains both numbers at once.
In the bus case the code grew 1.7× — because logic hidden inside a Java mediator came out into the open. Volume rose; nothing stayed concealed.
In the ORM case the code shrinks — because nothing comes out into the open here. What disappears is ceremony: eager-loading chains, manual object assembly from separate queries, a generic repository with specifications. That isn't business logic you can lose. It's the overhead of talking to an ORM.
The analysis author's estimate for the three heaviest spots — flagged as an estimate, not a measurement: an 810-line processor spending roughly 60% of its code on loading and manual graph assembly compresses to about 300; another at 495 lines with six separate queries to about 150; a 163-line filtering service to about 60.
Fewer lines here isn't the goal, it's the consequence. The goal is that six database round-trips to assemble one object become one. And that is about hardware: fewer queries for the same answer means less load on the same nodes.
Layer four: identity on someone else's stack, and HTTP where a call would do
Now the architecturally interesting part — and probably the most underrated in money.
What exists today: an external identity server. Four separate databases, 227 service tables — against the 53 that hold the actual business of this system. Roughly four to one in favour of authentication infrastructure.
Nine integration points with the bus are confirmed in the code: token issue, refresh, revoke, JWT decode and validation, password recovery and reset, user binding, group sync, adding a user to a group.
And here's the detail worth stopping on. Among the confirmed scenarios is service-account token retrieval for two channels — mobile and the control panel.
A service account is machine-to-machine. No user, no browser, no redirect. One part of the system proving to another that it is what it says it is.
The path of that request, per the architecture documentation, runs through several layers: client → bus → identity server → backend.
So to get a token that a machine needs to talk to a machine inside one perimeter, the system goes over HTTP through two intermediate layers. With serialisation, TLS establishment, timeouts, retries, and the diagnostics of "which of the four hops just failed."
What changes with an in-house identity server
In the in-house solution every entry point is registered on an internal transport that works in-process, without the network — synchronously, zero-copy, between modules of one runtime.
A module needing a service-account token gets it with a method call. No HTTP listener, no TLS handshake, no JSON over loopback. The exchange goes from the route straight into the handler and back, on the same thread.
The principled split that follows:
| Scenario | Needs a browser | Works over any transport |
|---|---|---|
| Service account (machine-to-machine) | — | ✅ |
| Redirect to the sign-in page | ✅ | — |
| Exchanging a code for a token | — | ✅ |
| Refresh, revoke, introspect, userinfo | — | ✅ |
| Management, user directory, audit | — | ✅ |
A browser is fundamentally required by exactly two interactions in the whole standard: the authorisation redirect, and the verification page for input-constrained devices. Everything else is transport-neutral.
Hence the main conclusion for this layer. The network between our own services appeared not because the protocol needs it, but because the identity server was someone else's, and HTTP was the only way to talk to it. Once the stack stops being someone else's, the network on that hop disappears as a category.
Plus 227 service tables of a foreign schema are replaced by two dozen typed schemas derived from classes. With no migrations, for the same reason as the previous section.
The system isn't sliced only in layers — and that multiplies everything
So far I've talked about horizontal layers: facade, integrations, data, identity. But that's how the system looks only on a presentation slide.
In reality a vertical slicing runs across the horizontal layers: control panel, mobile channel, GPS zone, geometry, SAP integration, outbound exchange, reference data, scheduling, key issuance. Fifteen module entry points in the codebase.
And here's what matters: each vertical drags every horizontal layer through itself. Its own facade, its own data access, its own models, its own database context wiring. An abstract context, a provider context, a context factory, a provider selection service, a design-time factory — all of that exists once per system, but every vertical is threaded into it.
So thirty thousand lines of data layer isn't "one layer out of four." It's what cuts across all fifteen verticals at once. Removing the ORM isn't extracting a module — it's pulling out the wiring that runs through every module.
Adding up what's measured — what leaves the repository:
| What | Files | Lines |
|---|---|---|
| Bus artefacts in XML plus build descriptors | 213 | 17,799 |
| Java: connector fork and class mediators | 18 | 4,099 |
| Data layer and database provider — written by humans | 96 | 12,672 |
| Routing engine and module container — not deleted, externalised as a dependency | 227 | 38,646 |
| Total written code leaving the repository | 554 | ≈ 73,200 |
| On top: generated migration artefacts | 4 | 17,407 |
Against roughly 175,200 lines written by humans. About forty-two percent of the codebase stops being something this team maintains — or about forty-seven counting the generated artefacts.
A word on those percentages, because they're easy to spin. I deliberately keep human-written and generated in separate rows: mixing the bases would be double counting. And "forty-two percent" is not "we deleted forty-two percent of the system." It's the share of code this team no longer answers for in this repository: some genuinely deleted, some turned into an external dependency.
What this does to development speed
Here's the cost line nobody puts in a budget and everybody pays.
When one vertical needs a change, how many competences do you call in? In the "before" stack: routing XML, Java for what XML can't express, Maven and artefact packaging, an ORM with migrations, a foreign identity schema of 227 tables. Even if one person knows all of it, they're switching between five contexts; if they don't, the task travels between people with losses at each seam.
In the "after" stack — C#. One. Routes, logic, data access, configuration, tests, debugging. One language, one pipeline, one debugger, one breakpoint through the whole chain from HTTP to database.
That changes not just the hourly rate but the time to enter a task. A new vertical can be written off the cuff: take the neighbouring one as a template, repeat the structure, change the content. Less up-front analysis is needed, because there are fewer decisions of the form "which language does this part live in" and "where will it run."
And separately — the thing enterprise-migration posts don't discuss yet, though it's already part of the working day. Uniform code in a mainstream language with recognisable patterns is written several times faster paired with a language model than heterogeneous code is. The reasons are prosaic: the model knows the mainstream language and the standard patterns; repeated module structure means a template sits right next door; typing and the compiler catch mistakes immediately rather than at runtime on the third node. Each of those cuts both time and the amount of context you have to hold — in your head or in the tool.
The reverse holds too, and you find out on the first attempt: synapse-XML with platform-specific extensions and name-based artefact links takes that help noticeably worse. Not because the format is bad, but because it's narrow and rare.
So where's the business benefit
All four layers, along the axes a business actually measures.
What you don't write and don't carry. The routing engine, the module container with dashboard and clustering, the identity server, the data access layer — none of it gets written in the project and none of it lands in the repository. Libraries arrive as packages, the runtime as an image or a signed archive. What stays yours is route declarations, business rules and entities. In the module we took apart: one class with custom logic in the whole module, everything else routes and configuration.
Hardware. One runtime per node instead of two side by side. One database round-trip to assemble an object instead of six. The network between our own services on the identity hop disappears entirely — four network hops become a method call.
Competences. Before: routing XML, Java for the gaps, Maven and bus artefact packaging, an ORM with migrations, a foreign 227-table identity schema. After: one language, one pipeline, one debugger. And — more importantly — that saving arrives in one step, when the last artefact comes off the last node, not proportionally to migrated modules.
Development hours. Where ORM ceremony leaves, code shrinks by multiples. Where logic emerges from hidden mediators, it grows — and that's the right kind of growth. Total volume is the wrong thing to watch. The right thing: about forty-two percent of the codebase stops being maintained here — some 73,200 lines of 175,200 written by humans, plus 17,407 lines of generated snapshots nobody reads but everybody commits.
Time to enter a task. One competence instead of five means a new vertical gets written off the cuff from a neighbouring template, with no prior decision about which language a part lives in. Uniform code in a mainstream language also pairs far better with a language model than a narrow declarative format with name-based links — and that's the working day now, not the future.
Operations hours. An entire class of operational events disappears: migrations with windows and rollbacks; manual restarts after cluster rebuilds; trips to a node to answer "what settings is it on"; picking apart last night's failure by hand in a broker.
Money on what no estimate covers. Silent failures. A consumer that quietly stopped reading a queue. An exchange hung on a shared channel. Four dead views nobody deletes because it's unclear who's watching them. None of these enter an estimate in advance, and each costs more than the ones that do.
The one number I won't give is what this is worth in currency. It depends on rates, team size, the local market for specific competences, and whether those competences sit in one pair of hands. I built an explicit model for the bus layer in part one; extending it across all four layers before three of them have happened would be selling an estimate as a measurement. What's measured here is code volume, table counts, integration points and topology. The conclusions from them are mine.
Honest limits
Three of the four layers haven't happened yet. This is the main caveat and it goes first. One module on the bus is done. Removing the ORM is a measured analysis of the codebase plus a three-phase pilot plan, not past tense: simple isolated entities first, then hierarchy, then the complex graph, and only then a decision about full removal. Moving to an in-house identity server is a chosen target. Everything named as a number is measured in current code; everything named as a consequence is a conclusion still to be proven by work.
One module isn't the whole system. The zone that moved had its boundary drawn from the start. Modules wired into internal zones, SAP and the identity server move harder, and these numbers don't extrapolate linearly.
The author is the architect and developer of this backend and the author of the stack it moves to. Stated at the top, repeated here: the store, the routing engine, the container and the identity server are mine. That's simultaneously the source of numbers nobody else has, and the reason to read the conclusions critically.
Code volume growth is real. ×1.73 raw, ×1.44 without blanks and comments. Some is explained by additions and typing, but not all: explicit code is inherently wordier than a declarative description. If volume matters to you, that's a price and you should know it.
The ORM comparison is incomplete on one axis. Some of the object store's query advantages — expression compilation to SQL, tree traversal via recursive CTE, change tracking — belong to the paid edition. The free one goes through JSON facets and is slower. If you're costing this move, pick the edition before you count, not after.
A storage paradigm change hits everything around the database. Data doesn't live in one table per entity. Anything looking at the schema from outside the application — reports, extracts, ETL, an analyst's hands in pgAdmin — will need adapting. The data stays in an ordinary relational database and is reachable by raw SQL, but familiar "select from the trips table" queries get rewritten. That's real work and it isn't in the estimates above.
ESBs keep their strengths. One administration point, out-of-the-box adapters, a model a team already fluent in it understands. If you have that competence and the failure modes from part one don't affect you, the move may not pay back.
An in-house engine isn't free. It cost 1,940–3,400 hours in part one. Those were spent earlier and elsewhere; a team without one doesn't start where this one started. Though that estimate is exactly what the accounting-asymmetry section at the top is about: it's computed under rules where a foreign platform's internals aren't counted at all.
I have no measurements on development speed. Not per vertical before and after, not for pairing with a language model. I consider the direction obvious and argue it from properties of the languages, not from a stopwatch. If you have numbers, they're more interesting than my reasoning.
Conclusion
The one-to-one comparison produced the opposite of what you'd expect: there is 1.7× more code (1,119 → 1,941 lines; 1.44× without blanks and comments).
But broken out by layer, that multiplier says the reverse of what it seems to. The route declaration shrank fourfold — 861 lines of XML against 212 of embedded language. What grew was the imperative logic, which in the bus lived as a separate Java project (4,099 lines across the whole project, 2,429 of them a fork of someone else's connector) and now sits in the same file as the route. On top of that, the new code is deliberately written spread out for the debugger and doesn't use the framework's own expression support — it could have been several times denser, whereas the XML was already at its floor.
What we got in exchange: three languages collapsed into one, all logic findable by code search, debuggable with an ordinary debugger, testable with ordinary tests. Exactly one class stayed custom — the one that had to.
And on the other three layers the picture inverts
Worth putting side by side, because the numbers went opposite ways and that isn't a contradiction.
Where logic was hidden — in Java mediators behind eighteen lines of XML — the code grew on porting. Hidden became visible, and volume honestly reflected it.
Where there was no logic at all but ceremony — fifteen hundred lines of model description for two partitioned tables, seventy files of eager-loading chains, a generic repository with specifications, five graph-assembly views of which four are dead — the code shrinks. There's nothing there to lose.
The difference between those two cases is the main practical takeaway of both posts. Before counting savings in lines, work out which one you're looking at: hidden logic, or overhead. In the first case volume will grow, and that's fine. In the second it will shrink — and that's not a reward for rewriting either, it's a sign you were paying to talk to your tooling.
And the largest saving isn't measured in lines on any of the four layers. It's the disappearance of operational events: migrations with windows and rollbacks, manual restarts after cluster rebuilds, four network hops where a machine asks a machine for a token inside one process, and a second area of expertise you must keep on staff as long as a single node still runs someone else's runtime.
If you've been down a similar road, post your ratio in the comments. Two things interest me: whether volume growth on the way from declarative description to explicit code is a rule or a quirk of our zone, and whether your numbers for code reduction after dropping an ORM match the estimate above. I'm happy to revise mine.
Sources and releases: github.com/redbase-app. About the redb store: redbase.app.
If this was useful — a ⭐ on GitHub helps others find it.

Top comments (0)