So far in this module, every problem has been about data. This last one is about people.
Three years ago, Tiffin had six engineers and one codebase. Today it has 90 engineers in 12 teams: ordering, payments, rider dispatch, restaurant tools, search, growth and more. They all still work in the same codebase, deploy the same app, and share the same database. The servers are fine. The database is fine. And yet shipping anything has become slow and stressful.
What it feels like
Here is a normal Tuesday at Tiffin:
- The payments team wants to ship a fix for a card provider's new error code. Their change is ready at 10am. It goes out at 4pm, because it's queued behind 23 other changes in the one daily deploy.
- At 4:20pm, the deploy is rolled back. A change from the growth team broke the referral page. The payments fix is rolled back with it.
- The dispatch team wants to try a new rider-matching algorithm. To be safe, they need someone from ordering to review it, because it touches the
orderstable that ordering also owns. That person is on leave. - The test suite takes 50 minutes and fails randomly once in every five runs, so everyone reruns it until it passes.
- A memory leak in the restaurant analytics code crashes the server process. Ordering goes down with it, at dinner time.
None of these are about load. They're about too many people changing one thing. Engineers call this a coordination cost: the time spent waiting for, checking with and recovering from each other.
Why it gets worse with size
With 6 engineers, everyone knows the whole codebase and talks to everyone. With 90, nobody knows all of it, and the number of people who might break your change grows much faster than the team.
The rough maths: the number of possible pairs of people who might need to coordinate is n x (n - 1) / 2. Six people make 15 pairs. Ninety people make 4,005. Teams don't talk to every other team, but the shared codebase means any change can affect anyone, so in practice you end up adding process: review rules, deploy queues, change freezes. Each one is reasonable. Together, they slow everyone down.
Past a certain size, the limit on how fast you ship is not your servers. It's how much teams have to coordinate to change anything.
Conway's law
In 1968, Melvin Conway observed that organisations tend to design systems that copy their own communication structure. The joke version that gets passed around: if four teams build a compiler, you get a four-pass compiler.
It works the other way too, and that's the useful part. If you want teams that can work independently, the system needs parts that can change independently. If payments, dispatch and ordering share one codebase, one database schema and one deploy, those teams can't be independent, however you draw the org chart.
So when Tiffin "splits the app", the real goal is not technical. The goal is to give each team a part of the system it can change, test and ship without waiting for anyone else.
Step one: split the code, not the deployment
The first move is not microservices. It's drawing clear lines inside the existing app.
A modular monolith is one deployable app whose code is divided into modules with clear owners, and where modules talk to each other only through defined interfaces.
For Tiffin, that might look like this:
%% caption: One app, one deploy, but each module has an owning team and a public interface. Other modules can't reach into its tables or internals.
flowchart TD
subgraph app ["Tiffin app: one process, one deploy"]
O[Ordering module] -- "charge(orderId, amount)" --> P[Payments module]
O -- "assignRider(orderId)" --> D[Dispatch module]
R[Restaurants module] -- "menuChanged(restaurantId)" --> S[Search module]
endConcretely, that means:
- Folders with owners.
src/payments/belongs to the payments team. ACODEOWNERSfile makes their review required for changes there. - A public interface per module. Ordering calls
payments.charge(orderId, amount). It doesn't import payments' internal classes or query thepaymentstables directly. - Table ownership. Each table belongs to one module. Only that module reads and writes it. Others ask through the interface.
- Enforced boundaries. A lint rule or build tool fails the build if
orderingimports frompayments/internal. Without enforcement, boundaries erode within months.
This solves more than you'd expect. Reviews stop needing the whole company. Tests can run per module. Teams understand their part deeply. Shopify's main Rails app, one of the largest in the world, has been organised this way on purpose for years rather than split into services.
What it doesn't solve: there's still one deploy, one test pipeline and one process. A memory leak in analytics still takes down ordering.
Find the hidden coupling
In Tiffin's code, the dispatch module calculates a rider's earnings by running SELECT SUM(delivery_fee) FROM orders WHERE rider_id = ? directly on the orders table. Why is that a problem for splitting the app later, and what would you do instead?
Show one answer
Dispatch now depends on the exact shape of the ordering team's table. If ordering renames delivery_fee or moves fees into a separate table, dispatch breaks, and nobody on the ordering team knows dispatch was reading it. When you later try to move ordering's data to its own database, this query can't follow.
Instead, ordering should expose it: either a function like ordering.deliveryFeesForRider(riderId, from, to), or an event every time an order is delivered ("order 81 delivered by rider 12, fee 40") that dispatch records in its own table. Either way, ordering is free to change its tables.
Step two: pull out what needs its own life
Some modules have a good reason to run as a separate app, with its own deploy and often its own database. That's a service. Common reasons:
| Reason | Tiffin example |
|---|---|
| Very different load or scaling | Rider location tracking: thousands of tiny writes a second, all day |
| Different reliability needs | Payments: must stay up and be changed very carefully |
| Needs to be isolated from failures elsewhere | Ordering should never crash because analytics leaked memory |
| A team ships far more often than the rest | Search tunes ranking several times a day |
| A different technology fits much better | A matching algorithm that's best written in Go or Rust |
Notice that "it's cleaner" isn't on the list. Each service you split out adds network calls that can fail, data that lives in two places, and a new thing to deploy, monitor and debug. Splitting out a service should solve a problem you can name.
Giving a module its own database is a step you can take on its own, even before it becomes a separate service. The sharding lesson covers this split by function, called federation, and why it usually comes long before sharding.
Signs it's time, and signs it isn't
Good signs:
- Teams regularly wait for each other's changes to ship.
- One part of the app needs to scale or fail independently of the rest.
- Module boundaries already exist and are respected, so you know where the lines are.
Bad signs:
- "Microservices are what big companies do."
- The team is small enough to sit at one table.
- Nobody can say where the boundaries should be. If you can't draw them inside one codebase, splitting it across the network will make things worse, not better.
That last one is the trap. A badly split system, where every request crosses five services and every feature needs four teams to deploy in a precise order, is called a distributed monolith. It has all the costs of microservices and none of the independence. It's worse than what you started with.
Where this goes next
This lesson is the "why" and the first step. The "how" is a whole module. Module 11 covers it: why most systems should start as one app, how to draw service boundaries, how services call each other, API gateways, finding services on the network, and tracing a request across many of them. Its lab picks the first service to carve out of Tiffin.
Choose the first move
Tiffin's search team ships ranking changes three times a day, but has to wait for the one daily deploy. Search reads restaurants and dishes but never writes them, and it already reads from its own Elasticsearch index. What would you do first?
Show one answer
Search is a good candidate for its own service. It has a clear boundary (it answers queries and never writes core data), it already has its own data store, and the team has a concrete, measured pain: waiting up to a day to ship.
Make search a separately deployed service that the main app calls for "find restaurants". The sync job that fills the index can move with it. The rest of the app stays a monolith. One clear win, with very little shared data to untangle, is a much better start than splitting everything at once.
What to remember
- Past a certain size, shipping slows down because of coordination between teams, not because of servers.
- Conway's law: systems mirror how teams communicate. Independent teams need parts they can change independently.
- The first step is a modular monolith: one deploy, but modules with owners, public interfaces and owned tables, with boundaries enforced by tooling.
- Pull a module out into a separate service only for a reason you can name: different scaling, reliability, isolation, release pace or technology.
- If you can't draw clean boundaries inside one codebase, splitting across the network makes it worse. That's a distributed monolith.
- Module 11 covers how to split into services in detail.