The previous lesson closed with a pattern: what holds up a system with many live versions is an explicit contract and a long coexistence between them. There we had one client app and one API, and the version table was watched by hand. Now we multiply the problem in another direction. Reservalia has grown: 1,900 paying businesses, four teams, and the apps/api that started out as a Fastify app with fifteen routes has been split into five services — appointments, businesses, notifications, payments and availability — that deploy on their own. The promise is a familiar one: autonomous teams that ship without waiting for each other. So is the price, and this lesson charges it in full. The pipeline is multiplied by five and with it every decision that used to be taken once. Each service is at once an old client and a provider to another, so the contract stops being a table and becomes an executable test with a deployment gate. A shared integration environment appears that, if you let it, turns into the bottleneck that cancels out all the autonomy you gained. And an organisational cost appears that almost nobody talks about until they pay it. By the end of the lesson we will be in a position to answer the question that saves the most money in the whole module: when not to split the monolith.
Contents
- What this context has that Reservalia did not
- The split and the dependency graph
- Monorepo or polyrepo, with selective execution
- The template pipeline: standardise without smothering
- Contract testing and the
can-i-deploygate - The shared integration environment and why it seizes up
- Independent versioning and deployment
- Compatibility in APIs and in events
- Progressive deployment and GitOps
- Distributed observability and deployment correlation
- The organisational cost and when not to split
- Case summary
- Common Mistakes and Tips
- Exercises
- Conclusion
- What this context has that Reservalia did not
| Monorepo with two apps (modules 2-4) | Five services, four teams | |
|---|---|---|
| Deployable units | 2 | 5, and growing |
| Who decides to deploy | The team, all in agreement | Each team, without warning |
| An incompatible change | Shows up in the same PR | Shows up in production, unless you prevent it |
| Integration tests | A docker compose with Postgres |
Against which version of the other four? |
| Rollback | One digest, 4 min | One per service, and sometimes two have to be reverted |
| Debugging a failure | One log, one service | A request that crosses four processes |
| Cost of a pipeline decision | Edit one file | Edit five, or have a mechanism |
The last row is what connects to 04-05 and explains why that work was done. The third is the heart of the lesson: in the monorepo, if Diego broke the signature of a function apps/web used, tsc failed in the same PR and nobody ever found out. With five repositories deploying separately, that same mistake compiles, passes the tests, gets deployed and breaks another team. The whole contract apparatus of section 5 exists to recover the signal the monorepo gave away for free.
And what does not change, which is still almost everything: CI and its six practices (02-01), an immutable artifact by digest (02-06), OIDC and least privilege (04-03), IaC (03-03), migrations with expand and contract (04-06), deployment strategies (03-04) and observability (03-06). None of those pieces gets replaced; they all get replicated and have to be governed.
- The split and the dependency graph
flowchart TD
W["web / mobile"] --> GW["API gateway"]
GW --> C["appointments"]
GW --> N["businesses"]
GW --> P["payments"]
C --> D["availability"]
C -.->|"AppointmentCreated event"| NT["notifications"]
P -.->|"PaymentConfirmed event"| C
N --> D
C --> BD1[("appointments db")]
N --> BD2[("businesses db")]
P --> BD3[("payments db")]
Two kinds of edge, and the difference decides almost everything else. The solid arrows are synchronous calls: appointments asks availability and waits for a response, so a failure or slowness in availability propagates upwards immediately. The dashed ones are events: appointments publishes AppointmentCreated and carries on; notifications consumes it when it can. A consumer being down does not break the producer, but it introduces a new problem: events are contracts too, with the aggravating factor that a published event can no longer be changed and may be consumed hours later (section 8).
Notice the databases too: one per service and no cross-access. That is the rule that makes deployment genuinely independent; the moment two services share tables, you have a distributed monolith with all the complexity of microservices and none of their advantages.
- Monorepo or polyrepo, with selective execution
The first question the team asked itself, and it has no universal answer:
| Monorepo (all 5 in one repo) | Polyrepo (one repo per service) | |
|---|---|---|
| A change touching two services | One atomic PR, reviewed together | Two PRs coordinated by hand |
| Refactoring a shared library | Every use is updated at once | Publish a version and wait for adoption |
| Isolation between teams | Weak: everybody sees and touches everything | Strong: permissions per repository |
| Pipeline | One, with mandatory selective execution | Five, with a shared template |
| CI time | Grows with the repo unless you filter | Naturally bounded |
History and git blame |
Noisy, mixes five products | Clean per service |
| Tooling needed | Affected graph, paths, remote cache |
Package registry, version management |
| Fits well with | Teams that collaborate a lot | Teams with autonomy and different rhythms |
Reservalia chooses a monorepo, for one specific reason: the five services share packages/shared and there are still refactors crossing boundaries every other day. With a polyrepo, each of those changes would mean publishing a package, waiting, and opening five PRs. The decision will be revisited when the teams stop touching each other, and changing your mind is not a defeat: it is the indicator working.
The price of the monorepo is that selective execution goes from optimisation to requirement. We already saw it in 04-04 with paths; with five services and dependencies between them you need a genuine affected graph:
detect:
runs-on: ubuntu-22.04
outputs:
services: ${{ steps.affected.outputs.list }} # 1
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # 2
- id: affected
run: |
BASE=$(git merge-base origin/main HEAD)
LIST=$(npx turbo run build --filter="...[$BASE]" --dry=json \
| jq -c '[.tasks[].package] | unique') # 3
echo "list=$LIST" >> "$GITHUB_OUTPUT"
verify:
needs: [detect]
if: needs.detect.outputs.services != '[]'
strategy:
matrix:
service: ${{ fromJson(needs.detect.outputs.services) }} # 4
fail-fast: false # 5
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare-node
- run: npm run verify --workspace services/${{ matrix.service }}- The detection job produces a list that the next one consumes as a matrix. It is the passing of information between jobs from 04-01, applied to deciding what runs.
fetch-depth: 0is essential: without the full history there is nomerge-baseand the diff calculation fails silently by running everything....[$BASE]includes the dependents, not just what was modified. If the PR touchespackages/shared, the filter returns all five services because they all depend on it. That is the nuance separating an affected graph from a simple folder filter, and it is exactly the "false green" trap 04-04 warned about: filtering bypathswithout following dependencies lets through changes that break a consumer.- The matrix is built at run time, so a PR touching only
notificationslaunches one job and not five. At Reservalia this brought the average down from 14 to 4.5 minutes. fail-fast: falseso that one broken service does not cancel the information about the others: whoever opens the PR wants to see all the reds at once.
- The template pipeline: standardise without smothering
Here the work of 04-05 pays off. With one service, a duplicated workflow is an annoyance; with five and growing, it is the guarantee that half of them will have neither dependency scanning nor actions pinned by SHA. The form it takes is a reusable workflow each service calls with its own parameters:
# .github/workflows/service.yml — the template, maintained by the platform team
on:
workflow_call:
inputs:
service: { required: true, type: string }
port: { required: false, type: number, default: 3000 }
publish_pact: { required: false, type: boolean, default: true } # 1
secrets:
AWS_ROLE: { required: true }
jobs:
verify:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/prepare-node
- run: npm run lint --workspace services/${{ inputs.service }}
- run: npm test --workspace services/${{ inputs.service }}
- if: inputs.publish_pact
run: npm run pact:publish --workspace services/${{ inputs.service }}
security: # 2 · not optional
uses: ./.github/workflows/security.yml
build-publish:
needs: [verify, security]
uses: ./.github/workflows/reusable-build-publish.yml # 3
with: { service: '${{ inputs.service }}' }# services/appointments/.github/workflows/ci.yml — what each team writes
name: appointments
on: { pull_request: {}, push: { branches: [main] } }
jobs:
ci:
uses: reservalia/platform/.github/workflows/service.yml@v3 # 4
with: { service: appointments, port: 3010 }
secrets: { AWS_ROLE: '${{ secrets.APPOINTMENTS_ROLE }}' }- There are few parameters and they have sensible defaults. A template with twenty inputs is not a template, it is a badly designed programming language; every new parameter is surface area to maintain forever.
- What cannot be switched off: the security job from 04-03. A team can choose its test runner, not whether it scans dependencies. That short list of non-negotiables is the core of the policy.
- The template reuses the reusable workflow from 04-05 instead of reimplementing it: composition, not duplication.
- The reference is pinned to a tag (
@v3), never tomain. A change to the template must not break five teams at once without them deciding to; by publishingv3.1and letting each team move up, the migration is gradual. It is the same discipline as pinning actions by SHA from 04-03, motivated by stability as well as security.
The underlying tension is real and it is not solved with YAML. Over-standardising produces a template full of exceptions and teams that copy the workflow to "fix it"; under-standardising produces five different pipelines, none with the module 4 practices complete. The rule that works for Reservalia:
| Standardised (mandatory) | Left to each team |
|---|---|
| Security scanning and severity policy | Test framework and style |
| Signing and publishing the artifact to ECR | Which tests and how many |
| Image and tag naming | Internal structure of the service |
Publishing contracts and can-i-deploy |
When to deploy and with which strategy |
| Emitting deployment events for DORA | Local development tooling |
- Contract testing and the
can-i-deploy gate
can-i-deploy gateThe problem, stated precisely: appointments calls GET /availability/:businessId?date=... and expects an array of slots with a start field. The availability team decides it should now be called from. Their tests pass, appointments' tests pass — they use a mock written months ago — both deploy and production breaks. The mock was the lie: it verified that appointments works against what appointments believes the other one returns, not against what it actually returns.
Consumer-driven contracts invert the direction. The consumer declares what it needs, that declaration is published to an intermediary (a broker), and the provider verifies in its own CI that it satisfies it.
// services/appointments/test/contracts/availability.pact.ts (consumer side)
describe('appointments → availability', () => {
it('returns the slots of a business on a given date', async () => {
await provider.addInteraction({
state: 'business 42 has a schedule on 25 October', // 1
uponReceiving: 'a query for slots',
withRequest: { method: 'GET', path: '/availability/42', query: { date: '2026-10-25' } },
willRespondWith: {
status: 200,
body: eachLike({ // 2
start: iso8601DateTimeWithMillis('2026-10-25T08:30:00.000Z'),
durationMin: like(30),
}),
},
});
const slots = await availabilityClient.query(42, '2026-10-25');
expect(slots[0].start).toBeDefined(); // 3
});
});- The
stateis a precondition the provider will have to set up in its verification. It is the point of agreement between the two teams, which is why it is written in business language. - Types and shape are declared, not values.
like(30)means "a number"; fixing it at 30 would make the contract fail every time the provider's test data changed, which is the fastest way for a team to abandon contracts. - The consumer test does double duty: it validates its client against a server simulated from the contract and, on passing, it publishes the contract to the broker with the version and the branch tags.
In availability's CI, the verification:
- name: Verify my consumers' contracts
run: npm run pact:verify
env:
PACT_BROKER_URL: https://pact.reservalia.internal
PACT_PROVIDER_VERSION: ${{ github.sha }}
PACT_CONSUMER_VERSION_SELECTORS: '[{"deployedOrReleased": true}]' # 1- The selector is the key and almost nobody configures it properly: verification runs against the contracts of the consumer versions actually deployed, not against every historical contract. Without this, the provider drags around the expectations of dead versions forever and can never evolve.
And the gate, which is what turns all of this from a report into a mechanism:
can-deploy:
needs: [build-publish]
runs-on: ubuntu-22.04
steps:
- name: can-i-deploy
run: |
pact-broker can-i-deploy \
--pacticipant availability \
--version "$GITHUB_SHA" \
--to-environment prod \
--retry-while-unknown 30 --retry-interval 10 # 1It returns green only if every consumer deployed to prod has its contract verified against this specific version of the provider. If appointments expects start and this version returns from, the deployment is stopped before it goes out. --retry-while-unknown covers the case of a verification still in progress: it waits instead of failing. It is the same class of gate as the quality one from 04-01, but with a new object: it does not check your code, it checks your compatibility with whoever depends on you.
- The shared integration environment and why it seizes up
The instinctive reaction of every team that splits into services is to set up an environment where all five are deployed and test there. It sounds reasonable and it always degrades the same way:
| Shared integration environment | Contracts + one environment per service | |
|---|---|---|
| What it tests | The complete system, real versions | Compatibility between pairs |
| When it gives the signal | After deploying there | In CI, before publishing |
| If something is broken | It blocks all five teams | It blocks only whoever broke it |
| Diagnosing a failure | Whose is it? A meeting | The specific pair, by name |
| Infrastructure cost | Five services always switched on | One plus simulations |
| Scales to 15 services | No | Yes |
The degradation mechanism is predictable: beyond three or four services, the probability that something is broken in the shared environment at any given moment approaches one. Then tests fail for unrelated reasons, the team learns to ignore reds, and the environment stops giving a signal precisely when there are most services. It is the same dynamic as the flaky tests of 02-04, but at organisational scale.
This does not mean you should never test the system as a whole. It means the shared environment stops being a gate and becomes a detector:
- The gates are the contracts and
can-i-deploy: fast, deterministic, with a clear owner, and they block deployment. - The detector is a small set of end-to-end journeys run against staging after deploying, and against production as a smoke test (03-02). If it fails, it is an incident, not a red check on a PR.
- Independent versioning and deployment
The rule, in one sentence: if deploying appointments forces you to deploy availability at the same time, you do not have microservices. A "coordinated release of every service" reintroduces all the costs of the split and removes its only advantage, because the batch is large again, the lead time syncs with the slowest service and one failure forces you to revert five things.
| Independent deployment | Coordinated deployment | |
|---|---|---|
| Batch size | One change from one team | The sum of five teams |
| A failure | One service is reverted | You have to work out which of the five |
| Cadence | Each team's own | The slowest one's |
| Requires | Backward compatibility always | Nothing: that is why it is tempting |
The last row is the honest one: coordinated deployment is tempting because it allows incompatible changes. Giving it up demands the discipline of the next section. And each service versions on its own, with semantic-release over its own commits (02-06); there is no such thing as "the Reservalia version" and there is no need for one. What does exist is a record of which version of each service is in each environment, which is precisely what the contract broker already knows and what makes can-i-deploy possible.
- Compatibility in APIs and in events
The whole discipline of expand and contract (04-06) and of version coexistence (05-02) applies here, with one addition of its own: events are worse than APIs. A synchronous API is consumed now and you know who is calling; an event is consumed later, perhaps by a new service that does not yet exist, and events already published are sitting in the queue and cannot be changed.
Reservalia registers its event schemas in a central registry, and CI validates compatibility before letting a new one be published:
{
"type": "record",
"name": "AppointmentCreated",
"fields": [
{ "name": "appointmentId", "type": "string" },
{ "name": "businessId", "type": "int" },
{ "name": "startUtc", "type": { "type": "long", "logicalType": "timestamp-millis" } },
{ "name": "channel", "type": ["null", "string"], "default": null }
]
} - name: Check schema compatibility
run: |
curl -sf -X POST "$REGISTRY/compatibility/subjects/AppointmentCreated/versions/latest" \
-H 'Content-Type: application/json' \
--data-binary @schemas/AppointmentCreated.avsc | jq -e '.is_compatible == true' # 1- CI fails if the new schema is not compatible with the previous one under the configured policy. It is one more gate, of the same kind as
can-i-deploy, but for the asynchronous channel.
What is compatible and what is not, with the practical rule:
| Change to the event | Safe? | Why |
|---|---|---|
| Adding a field with a default value | ✅ | Old consumers ignore it |
| Adding a mandatory field | ❌ | An old consumer cannot read it… and a new one cannot read the old messages |
| Removing a field with a default | ✅ (after waiting) | Only if no consumer uses it; verify first |
| Renaming a field | ❌ | It is removing and adding at once |
| Changing a field's type | ❌ | Except for very specific widenings |
| Changing the meaning without changing the type | ❌❌ | The worst of all: no tool detects it |
The last row deserves a warning. Switching startUtc from local time to UTC without changing the name or the type is compatible as far as the validator is concerned and catastrophic for consumers, who will carry on interpreting the number as before. When the meaning changes, change the field name or the event version; the registry checks structure, not semantics. And one recommendation that saves incidents: consumers should be tolerant — ignoring fields they do not know instead of failing — because that is what lets the producer add things without coordinating anything.
- Progressive deployment and GitOps
The strategies from 03-04 are still the same and are not re-explained; what changes is that there are now five simultaneous progressive deployments and nobody is going to watch them by hand. With the move to Kubernetes — whose detail belongs to 06-05 — progressive deployment is declared like everything else:
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata: { name: availability }
spec:
strategy:
canary:
steps:
- setWeight: 10 # 1
- pause: { duration: 5m }
- analysis: # 2
templates: [{ templateName: error-rate-5xx }]
- setWeight: 50
- pause: { duration: 10m }- The canary steps are declarative: weights and waits written next to the service, not a script in the pipeline. With five services, this is what stops five different implementations of the same canary appearing.
- The automatic analysis queries a metric and aborts the deployment if it gets worse. It is the metric-driven automatic rollback from 03-05, now as part of the deployed object rather than as a CD job.
And this is where GitOps appears out of necessity rather than fashion. With two apps, cd.yml pushed the deployment from the pipeline (03-02) and it worked. With five services and several environments, that model requires CI to hold write credentials over every cluster — exactly what 04-03 wants to minimise — and makes it hard to answer "what is deployed in prod right now?". The pull model inverts the direction:
flowchart LR
CI["Service CI<br/>publishes image by digest"] --> PR["Automatic PR to the<br/>deployments repository"]
PR --> RV["Review and merge"]
RV --> G["Desired state repository<br/>git"]
G --> AG["Agent in the cluster<br/>synchronises"]
AG --> K["Cluster"]
K -.->|"drift detected"| AG
Three practical consequences that justify the change. The desired state is in git, so the question of what is deployed is answered with git log and not by querying the cluster. CI no longer needs cluster credentials: only permission to open a PR, which greatly reduces the surface area from 04-03. And drift is detected and corrected on its own: if somebody changes something by hand in the cluster, the agent returns it to the declared state, which is the same promise Terraform made for infrastructure in 03-03, now for applications.
- Distributed observability and deployment correlation
The three pillars and the four golden signals of 03-06 still hold; what changes is that a request no longer lives in one service. Without distributed tracing, investigating "booking takes 4 seconds" is a meeting of four teams each saying their part is fine.
The bare minimum is three things, and all three are standardised in the template from section 4 so that they do not depend on each team's goodwill:
- Propagate the trace context between services and through events. In HTTP calls it travels in the
traceparentheader; in events it has to be copied inside the message by hand, and that is what almost everybody forgets: the trace is cut off exactly as it crosses the queue, which is where it is needed most. - Label everything with
serviceandversion, so you can compare the behaviour of two versions during a canary. - Emit a deployment marker per service to the shared dashboard, with service, version, digest and time.
The marker is what solves this context's specific problem. The typical case: at 11:20, appointments starts returning 500 errors and its team reviews their changes without finding anything, because their last deployment was the day before yesterday. The answer is on the shared deployments dashboard, where an availability marker appears at 11:18. Without that dashboard, that correlation takes an hour of meeting; with it, thirty seconds.
-- What was deployed in the 30 minutes before an incident?
SELECT service, version, deployed_at
FROM deployments
WHERE deployed_at BETWEEN '2026-08-14 10:50' AND '2026-08-14 11:20'
ORDER BY deployed_at DESC;With that table — the same one that fed the DORA metrics in 03-06, now with a service column — the question "what changed?" has an objective answer, which is the first question of any incident and the one that eats the most time when it cannot be answered.
- The organisational cost and when not to split
What nobody puts in the initial proposal: somebody has to maintain the pipeline template, the contract broker, the schema registry, the deployments repository, the cluster, the shared dashboard and the conventions. At Reservalia that is two full-time people who do not build product. It is a legitimate decision with 1,900 businesses and four teams; with 340 businesses and three people it would have been absurd.
The checklist before splitting a monolith, in order of importance:
| Signal | Does it justify splitting? |
|---|---|
| Several teams block each other when deploying | ✅ The good reason, and almost the only one |
| Parts with very different scaling needs | ✅ If the difference is an order of magnitude |
| A domain with its own isolation or compliance requirements | ✅ Payments, for example |
| The pipeline takes 40 minutes | ❌ That is fixed with 04-04 |
| "The code is a mess" | ❌ Distributed modules get just as messy, plus the network |
| "It is what everybody does" | ❌ Almost nobody has your problem |
| A team of three people | ❌❌ You will pay for coordination without needing it |
Diego: "How much is this costing us a month?" With numbers: infrastructure goes up by 35% — five deployments, more networking, more observability — CI multiplies but selective execution offsets almost all of it, and the real cost is the two platform people. In exchange, four teams deploy without waiting for each other, which was the blockage that motivated the whole thing.
And the alternative almost nobody considers: the modular monolith. Strict internal boundaries, modules with explicit interfaces, a ban on cross-access to tables and a pipeline with selective execution per module. It gives most of the organisational benefit with none of the network costs, and it has an extremely valuable property: it is the natural preceding step, because a monolith that is already modularised can be split later with far less effort than a tangled one. If in doubt, start there.
- Case summary
| Context | 5 services, 4 teams, 1,900 businesses; synchronous calls and events; one DB per service |
| What still holds as-is | CI (02), artifact by digest (02-06), OIDC and least privilege (04-03), IaC (03-03), strategies (03-04), expand and contract (04-06), observability (03-06) |
| Decision 1 | Monorepo with an affected graph that includes dependents; CI average from 14 to 4.5 min |
| Decision 2 | Template workflow pinned to a tag, with a short list of non-negotiables |
| Decision 3 | Consumer-driven contracts + can-i-deploy as a pre-deployment gate |
| Decision 4 | The shared environment stops being a gate and becomes a detector |
| Decision 5 | Event schema registry with compatibility validation in CI |
| Decision 6 | GitOps: CI opens a PR to the state repository; the cluster pulls |
| Cost | +35% infrastructure and 2 full-time platform people |
| Effect on DORA | Frequency per team ×3; lead time equal or better; CFR rises to 5.1% for the first three months and returns to 3.9% once the contracts mature |
| What you take to any project | What the compiler gave you for free inside one process has to be bought between services with executable contracts |
Common Mistakes and Tips
Mistake 1: sharing a database between services. That is the distributed monolith: all the costs, none of the advantages, and independent deployment ceases to exist. Mistake 2: filtering by paths without following the dependency graph, so a change in packages/shared does not run the tests of whoever uses it: the false green from 04-04.
Mistake 3: trusting hand-written mocks as if they were contracts; they verify what you believe, not what the other side returns. Mistake 4: verifying contracts against every historical version instead of against the deployed ones, so the provider can never evolve. Mistake 5: turning the shared environment into the deployment gate, which beyond four services is permanently broken and trains the team to ignore reds.
Mistake 6: coordinated releases of every service, which bring back the large batch and cancel out the reason for splitting. Mistake 7: changing the meaning of a field without changing its name; no schema registry detects it. Mistake 8: not propagating the trace context through events, cutting the trace off exactly where it is needed most. Mistake 9: splitting the monolith for code problems rather than organisational ones.
Tip 1: pin the pipeline template to a tag and migrate the teams one by one. Tip 2: keep the list of non-negotiables short — security, artifact publishing, contracts, DORA events — and leave the rest to each team. Tip 3: make consumers tolerant so they ignore unknown fields. Tip 4: if in doubt, modular monolith first; it is the preceding step and it always works out cheaper.
Exercises
Exercise 1
The availability team needs to change the start field to from in the GET /availability/:businessId response, and appointments and businesses are consumers. Design the complete sequence of changes and deployments, indicating what happens at each step with can-i-deploy and at what moment it is safe to remove the old field. Explain as well why the reverse order — changing the consumers first — does not work either without the coexistence phase.
Exercise 2
A PR modifies only packages/shared/src/availability.ts. The workflow uses paths: ['packages/shared/**'] and runs a job that compiles and tests that package; it comes out green and is merged. Two hours later, appointments fails in production with a runtime type error. Explain the flaw in the pipeline's design, propose the specific fix and say which other gate in the system should have caught it even if the filter had been right.
Exercise 3
Marta raises this in the architecture meeting: "Reservalia now has 1,900 businesses and four teams; should we also split businesses, which is the largest service, into profiles, schedules and billing?". Build an analysis using the lesson's criteria and a reasoned recommendation, including what data you would ask for before deciding.
Solutions
Solution 1. It is expand and contract (04-06) over an HTTP contract, with the peculiarity that the ones who have to move are other teams. The sequence:
Step 1 — Expand the provider. availability returns both fields, start and from, with the same value. Its own tests are updated, but the contracts published by appointments and businesses still require start, which is still present: the verification passes and can-i-deploy goes green. It is deployed with no coordination at all. Step 2 — Migrate the consumers, each at its own pace. The appointments team changes its client to read from and, with that, its published contract changes: it now requires from. In its CI, before merging, the verification runs against the version of availability deployed to prod, which already returns both fields, so it passes. Its can-i-deploy goes green and it deploys. businesses does the same the following week, or the following month: no synchronisation is needed, and that is exactly the property bought with the whole apparatus. Step 3 — Contract. availability removes start. Here is where it gets interesting: if businesses has not migrated yet, its contract still requires start, the verification fails and can-i-deploy goes red, stopping the deployment before it is published. The provider does not need to ask anybody or keep a spreadsheet of who has migrated: the gate knows. When both consumers have migrated and their deployed versions are recorded, the red turns green on its own.
The safe moment to remove it is therefore "when can-i-deploy allows it", which is an operational answer and not a date in a calendar. Two cautions are worth adding: that the consumers deployed to prod are the ones that count (the selector from section 5), because a consumer that migrated on its branch but has not deployed yet protects nothing; and that there may be consumers outside the broker — an internal script, a large customer with direct access — that the gate cannot see and that have to be tracked down by hand.
Why the reverse order does not work: if appointments changes first to read from, its contract requires a field the deployed provider does not have, the verification fails and can-i-deploy gives it a red. Even if you disabled the gate, in production appointments would read undefined. The coexistence phase is not bureaucracy: it is the only state in which both sides are valid at once, and that is why neither direction works without it.
Solution 2. The flaw has two layers and it is worth separating them. The filter layer: paths is a condition on which files changed, not on what is affected. A change in packages/shared affects the five services that import it, but the filter only triggers the package's job. That job compiled and tested the package in isolation — where the change is self-consistent — and came out green. The "false green" of 04-04 in its purest form: the check does not say "the system works", it says "what I ran works", and what it ran was a fraction.
The specific fix: replace the folder filter with the affected graph from section 3, with a filter that includes the dependents (...[BASE] in Turborepo, --affected in Nx, bazel query rdeps in Bazel). A PR touching packages/shared then runs all five services; one touching only services/notifications still runs one. It is also worth checking two details that make the graph fail silently: that the checkout brings enough history to compute the merge-base, and that the dependency is genuinely declared in the service's package.json — if it is imported by relative path bypassing the workspace, no tool can see it.
Which other gate should have caught it: the contracts. If the change altered the shape of what availability returns to appointments, the provider's contract verification would have failed, and can-i-deploy would have blocked the deployment even with the filter wrong. That it did not catch it indicates the change affected something the contracts do not cover — a shared internal calculation function, not the shape of a response — and there the correct defence is the first one. It is a general lesson from module 4 that shows up very clearly here: the gates overlap on purpose, and an incident that gets through several of them points at which layer was missing. A third cheap reinforcement: publishing @reservalia/shared as a versioned package instead of consuming it through the workspace would force an explicit update in each service, turning an invisible change into a visible PR per service, at the cost of losing the atomic refactor.
Solution 3. The analysis, criterion by criterion. (1) Is there blockage between teams? That is the decisive question. If profiles, schedules and billing are maintained by the same team, splitting removes no blockage and adds three pipelines, three deployments, three databases and contracts between pieces that today share a process. The default answer would be no. If instead there are two different teams treading on each other in the same repository and waiting for each other to deploy, the conversation changes. (2) Different scaling? schedules probably takes far more read traffic than billing, but the right question is whether the difference is an order of magnitude and whether it is causing a real cost or saturation problem today. If the whole service fits in three ECS tasks, the answer is no. (3) Isolation or compliance? billing is the only serious candidate: if it handles tax or payment data with its own audit or retention requirements, isolating it has a value that is not technical. (4) Data coupling? The point that usually kills the proposal: if schedules and billing query the same business tables, splitting them requires duplicating data or introducing synchronous calls on a critical path, and the result is slower and more fragile than the original.
The data I would ask for before deciding, all obtainable within a week: how many PRs a month touch each area and whether they come from different people; how many times in the last three months a businesses deployment was delayed waiting for a change in another part; the split of traffic and cost across the three areas; the real graph of internal imports and table accesses inside the service, which is what tells you whether the boundaries exist or are a wish; and the lead time and change failure rate of businesses compared with those of the other four services, to find out whether there is a measurable problem or just a feeling.
Recommendation: do not split yet, and instead build the modular monolith inside businesses — three modules with explicit interfaces, a ban on cross-access to tables enforced in CI with an import rule, and selective execution per module in the pipeline. That delivers most of the organisational benefit today, adds not a single network call, and leaves the service in a state where splitting it tomorrow is almost mechanical if criterion (1) ever comes to hold. The only exception I would consider immediately is billing, and only if a concrete compliance requirement exists that cannot be met today. It is exactly tip 4 of the lesson: when in doubt, the reversible option wins, and modularising is reversible whereas splitting is not.
Conclusion
Microservices have put the pipeline through the hardest test in the module, and the outcome is not that anything has to be thrown away, but that everything multiplies and needs governing. Selective execution stopped being an optimisation from 04-04 and became a requirement, with a nuance that separates a correct pipeline from one that lies: you have to follow the dependency graph, not filter by folder, or green stops meaning anything. The pipeline-as-code work of 04-05 paid off in full in a tag-pinned template, with a short list of non-negotiables and freedom in everything else. And where the monolith gave the signal for free — the compiler stopping Diego breaking apps/web — it had to be bought with executable contracts: consumer-driven contracts, verified by the provider against the versions actually deployed, and a can-i-deploy gate that stops the deployment before it goes out. That mechanism is what allowed the shared integration environment to be demoted from gate to detector, avoiding the bottleneck that cancels out autonomy beyond four services. Around it, the same compatibility discipline as always, extended to events with a schema registry and with the warning that no validator detects a change of meaning; progressive deployment declared next to the service with automatic metric analysis; GitOps out of necessity, because the pull model reduces CI's credentials and answers what is deployed with git log; and traces propagated through queues too, with per-service deployment markers on a shared dashboard that turns an hour of meeting into thirty seconds of querying. The cost, stated plainly: 35% more infrastructure, two full-time platform people, and a change failure rate that rose to 5.1% for three months before coming back down. That is why the lesson ends with the list of when not to split, and with the reminder that a modular monolith delivers nearly all the organisational benefit with none of the network costs.
The three cases seen so far shared a starting condition: they were modern systems, with tests, with healthy version control, with teams that could decide how to work. The module's last lesson removes that net. Gestor Citas 4 is the company's previous product — a Java/JSP monolith on Tomcat from 2011, without a single automated test, deployed by FTP on Saturday nights, with the configuration edited by hand on the server and three large customers still paying for it — and it cannot be rewritten. There it is not about choosing between monorepo and polyrepo or tuning a canary: it is about deciding where to start when there is nothing, in what order to build the increments so that each one delivers value on its own, and how far it is worth going on a product that is only in maintenance.
CI/CD Course: Continuous Integration and Deployment
Module 1: Introduction to CI/CD
- Basic CI/CD Concepts
- Benefits of CI/CD
- Popular CI/CD Tools
- The Course Project: the Application We Are Going to Automate
- DORA Metrics: How Software Delivery Is Measured
Module 2: Continuous Integration (CI)
- Introduction to Continuous Integration
- Setting Up a CI Environment
- Build Automation
- Automated Testing
- Code Quality and Static Analysis
- Artifacts, Versioning and Promotion
- Integration with Version Control
Module 3: Continuous Deployment (CD)
- Introduction to Continuous Deployment
- Deployment Automation
- Infrastructure as Code and Reproducible Environments
- Deployment Strategies
- Feature Flags, Rollback and Failure Recovery
- Monitoring and Feedback
Module 4: Advanced CI/CD Practices
- CI/CD Pipelines
- Dependency Management
- Security in CI/CD
- Scalability and Performance
- Pipeline as Code: Templates, Reuse and Testing the Pipeline
- Databases in the Pipeline: Safe Migrations
Module 5: Implementing CI/CD in Real Projects
- Case Study: Web Project
- Case Study: Mobile Application
- Case Study: Microservices
- Case Study: Modernising a Legacy Project
Module 6: Tools and Technologies
- Jenkins
- GitLab CI/CD
- CircleCI
- Travis CI
- Docker and Kubernetes
- GitHub Actions in Depth
- Comparison and Criteria for Choosing a Tool
Module 7: Practical Exercises
- Exercise 1: Setting Up a Basic Pipeline
- Exercise 2: Integrating Automated Tests
- Exercise 3: Deploying to a Production Environment
- Exercise 4: Monitoring and Feedback
- Exercise 5: Hardening the Pipeline with Security and Secrets
- Final Project: A Complete End-to-End Pipeline
