Yago Marinho
Back to the articles
  • Microservices
  • Architecture
  • AWS
  • Docker
  • FFmpeg
  • NodeJS

Davna AI: when I decide to refactor a monolith into a microservice

In Davna, only FFmpeg left the monolith. I explain why — binary size and CI/CD —, compare EC2 against API Gateway and Lambda costs without taking sides and show how I test each side.

Photo of Yago MarinhoBy: Yago MarinhoLast updated on:
💡
Almost every discussion about microservices starts with the answer instead of the question. "Microservices scale better", "a monolith is legacy", "a modular monolith solves everything". Each of these statements is true in some context and false in another — and the context is exactly the part that usually gets left out.
In Davna, the architecture has one monolith, monolith.api, and a single microservice, multimedia.processing, which runs FFmpeg and ffprobe. It wasn't an accident, nor a step in a planned migration: it was a one-off decision, made for concrete reasons. And everything else stayed in the monolith, also for concrete reasons.
This article is about those reasons. About when I split a piece of the system out, when I don't, how much each choice costs and how each one changes the way I test.

Table of contents

The starting point: an audio file with no duration

A quick recap of the problem that brought FFmpeg into the project: the WebM produced by the browser's MediaRecorder often arrives with no duration in its header. And duration is the foundation of Davna's usage control — each student has a daily limit in minutes of conversation.
The solution was to convert every incoming audio file to AAC and, after the conversion, read the real duration with ffprobe. It worked. But the next question was: where does this code live?
The obvious answer would be inside the API. It already receives the audio, already talks to S3, already knows the domain. All it would take is installing FFmpeg and calling spawn. It was precisely by looking at what that "all it would take" dragged along that the decision changed.

The weight that comes with the binary

FFmpeg is not a JavaScript library. It's a native binary — and a big one. In Davna, it comes in through the ffmpeg-static and ffprobe-static packages:
  • ffmpeg-static → downloads the platform binary at install time. The Linux x64 one alone is close to 80 MB;
  • ffprobe-static → publishes the binaries for every platform to npm at once: the unpacked package is over 350 MB.
If all of this lived inside the API, every API image would carry that weight. Including the image built to fix an error message, tweak a socket handler or change a usage limit rule. In practice, that means:
  • Slower builds → installing dependencies downloads and copies hundreds of megabytes on every build;
  • Bigger pushes and pulls → every deploy uploads the image to the registry and the instance downloads it back;
  • A larger attack surface → a native binary with dozens of codecs lands in the same image and the same container that hold the tokens and talk to the database.
And the most annoying part: FFmpeg almost never changes. The AAC conversion was written once and, since then, I've only touched it to adjust the timeout. The API, on the other hand, changes all the time. Putting both in a single image makes the part that never changes pay the price of the part that always does.

CI/CD: shipping everything on every change

This mismatch in pace shows up even more strongly in the pipeline.
With the two split apart, the GitHub Actions workflow started asking one question before anything else: what changed? A path filter (dorny/paths-filter) decides whether the API job, the FFmpeg job or both need to run:
- name: Detect changed paths id: paths uses: dorny/paths-filter@v2 with: filters: | api: - 'apps/api/**' ffmpeg: - 'apps/ffmpeg/**'
Each image is built in two stages, with the tests running inside the build stage: if a test fails, the image never even exists. Then the self-hosted runner on EC2 updates only the container that changed.
In a monolith with FFmpeg inside, that question doesn't exist. Any commit rebuilds the whole image, runs the whole suite, pushes the whole image and restarts the whole process — including the audio conversions that were halfway through.
This isn't a problem unique to Davna. On this very blog, each Netlify site only builds when its own path changes; the reasoning is the same, just at the site level instead of the container level.

Splitting is not moving to another machine

There's an argument that shows up in almost every conversation about FFmpeg, and that I used myself at some point: "converting audio uses a lot of CPU, so it can't compete for room with the API". It sounds like a reason to split. It isn't.
First, because even inside the monolith FFmpeg would never compete for the Node.js event loop. It's called through spawn, as a separate operating system process. Node only writes the audio to stdin and reads the result from stdout; what burns CPU is the FFmpeg process, not the API.
Second, because today both containers run on the same EC2 instance, alongside MongoDB, orchestrated by Docker Compose. The CPU is the same. If the whole machine runs out of breath, both suffer together — exactly as they would inside a monolith.
Splitting out a microservice is a structural decision: it gets its own image, its own deploy and a contract that goes through the communication layer. Where it runs is an infrastructure decision, made later and for other reasons. Splitting doesn't solve CPU contention; it only makes it cheap to solve on the day it actually happens. Since the API talks to FFmpeg only through the SDK, over HTTP, moving the service to another machine, scaling it on its own or putting it on a Lambda means changing a URL in the configuration — not a refactoring.

Why everything else stayed in the monolith

If splitting FFmpeg out brought so many advantages, why not split the classroom, identity and monitoring too?
Because those modules have neither of the two reasons above. They don't carry a heavy binary, and they change at the same pace as the rest of the API. And they are already separated where it matters: in the code. Each one is a module in modules/, with its own handlers, contracts and repositories. The boundary exists — it just doesn't go through the network.
And going through the network has a cost that is usually underestimated. A function call:
  • has no network latency;
  • doesn't fail with a timeout;
  • needs no retries or idempotency;
  • needs no contract versioning;
  • is checked by the compiler.
An HTTP call between services has none of these guarantees. Every boundary that becomes a network hop starts demanding service-to-service authentication, partial failure handling, distributed observability and a coordinated deploy whenever the contract changes.
That's why my rule is simple: a piece leaves the monolith when it has a dependency or a deploy pace that differs from the rest. "Being more organized" isn't on the list — organization is solved with modules. And neither is "using a lot of CPU": that's solved by choosing where each thing runs, which only becomes easy once the network boundary already exists.

What each one costs: EC2 versus API Gateway and Lambda

Cost is where discussions tend to turn into cheering for a team. I'll try to do the opposite: show where each model is cheap and where it gets expensive.
The prices below are on-demand prices for us-east-1 (N. Virginia), checked in October 2026, not counting the free tier.
  • EC2 t3.small (2 vCPU, 2 GB) → ~$0.0208 per hour, or ~$15 per month;
  • Lambda, requests → $0.20 per million;
  • Lambda, compute (x86) → ~$0.0000167 per GB-second;
  • API Gateway (HTTP API) → $1.00 per million requests.

FFmpeg as a Lambda

Picture a conversion that takes 2 seconds on a Lambda with 1 GB of memory:
  • compute: 2 GB-s × $0.0000167 ≈ $0.0000333;
  • Lambda request: ≈ $0.0000002;
  • API Gateway request: ≈ $0.000001;
  • total: around $0.000035 per conversion.
Dividing the t3.small's $15 by that amount, the break-even point lands at around 430,000 conversions per month — something like 14,000 a day, or ten a minute, all day long.
Below that, Lambda is cheaper, and by a wide margin: with few students, FFmpeg would cost cents per month, against the same fixed $15 for an instance left running at dawn with nobody using it. Above that, the Lambda bill grows linearly, while the instance keeps costing the same up to its own limit.

What the table doesn't show

Both sides have hidden costs, and this is where the comparison gets fair:
On EC2:
  • CPU credits → the t3 family is burstable. Converting audio all day burns through the credits, and from then on either the CPU gets throttled or AWS charges for the surplus (in unlimited mode);
  • Disk, backups and data transfer → EBS and outbound data are billed separately;
  • Your time → updating the operating system, renewing certificates, looking after Docker, the self-hosted runner and disk space. It doesn't show up on the invoice, but it is a cost.
On Lambda:
  • Execution limits → up to 15 minutes per invocation and up to 250 MB unzipped between the function and its layers. FFmpeg fits, but ffprobe-static with every platform doesn't — it would have to ship only the Linux binary, or the function would have to be a container image (up to 10 GB);
  • Audio size → in a synchronous invocation, the Lambda request and response are capped at 6 MB each. A one-minute utterance fits comfortably; a long recording would have to go through S3, with the Lambda reading and writing there instead of receiving the file in the body;
  • Cold start → the first conversion after an idle period is slower, and the student notices;
  • The architecture around it → the rest of Davna doesn't fit as well. The API uses Socket.IO, and persistent connections on Lambda require API Gateway's WebSocket API, with a different programming model and billing per message and per connected minute. MongoDB also suffers when dozens of functions open connections at the same time.
In other words: for FFmpeg, which is a pure function — audio in, audio out —, Lambda is an excellent candidate. For the API, which keeps sockets open and talks to a database all the time, an always-on instance is the natural fit. The answer changes per piece of the system, not for the system as a whole.

Testability: what changes on each side

The claim that "microservices are easier to test" is a half-truth. Each side is easier to test in one particular way.

In the monolith, the whole flow fits in memory

In monolith.api, handlers are pure functions that receive their dependencies through Reader. To test a full conversation turn — receiving the audio, transcribing, replying, consuming the limit —, I inject fakes: an in-memory repository, an in-memory storage and the FakeAI in place of OpenAI. The test goes through several modules without opening a port, without starting a container and without spending a cent.
And when an internal contract changes, TypeScript flags every place that uses it, before any test runs.

In the microservice, each side tests itself — and the middle is left uncovered

multimedia.processing has its own tests: the conversion and metadata handlers, and the services that call the real FFmpeg. monolith.api tests its side with a fake SDK. Both sides go green.
The problem is what sits between them. Look at how the SDK reads the duration coming back from the service:
duration: response.headers.get('x-file-duration') ? parseFloat(response.headers.get('x-file-duration') as string) : 0,
If the service ever renames that header, no compiler will complain and no unit test will break. The SDK will return a duration of zero — and the usage control, the very reason all of this exists, will silently stop working.
That's the price of a network boundary: the contract stops being a type and becomes a convention. That's why Davna's pipeline starts the containers with Docker Compose and runs integration tests before publishing the images. They are slower and more brittle than unit tests, but they are the only ones that can see the middle of the road.
To sum up the trade-off:
  • Monolith → better at testing the whole flow; internal contracts are checked by the compiler;
  • Microservice → better at testing each part in isolation; contracts between services need their own tests (integration or contract tests) so they don't break silently.

When the next piece will leave

If you've read this far expecting me to announce the migration of all of Davna to microservices, I'm going to disappoint you. Migrating isn't something I want for its own sake — it's a response to signals the system gives. And I split those signals into two kinds, because they call for different responses.
The ones that call for moving to another machine, not changing the structure:
  • The instance's CPU → if conversions start competing with the API for the machine, FFmpeg moves to another instance or to a Lambda. The code doesn't change: the API will only notice a different URL in its configuration;
  • The audio queue → if students start waiting for conversions, the natural path is a queue (SQS) in front of FFmpeg, to absorb spikes without losing messages.
The ones that call for splitting out a new service:
  • Different paces of change → if one part of the system starts changing at a very different pace from the rest, or starts being owned by someone else, it gets its own deploy;
  • Dependencies nobody else uses → if one part starts dragging along libraries or binaries that only it needs, as happened with FFmpeg;
  • Different availability requirements → if one part needs to stay up when another goes down, they can't be the same deploy.
And the next candidate is already on the roadmap: the conversation turn orchestrator. Today, transcribing, generating the teacher's reply, translating and synthesizing the voice all happen inside the socket handler. It's the part of the system that changes the most: the teacher's prompt, the order of the steps, which ones can run in parallel, which model to use for each. It has already started taking shape in the repository as interaction.conductor, but it isn't in production yet.
Notice that it meets the same criterion as FFmpeg, from the opposite direction: FFmpeg almost never changes, and the orchestrator changes all the time. In both cases, the deploy pace differs from the rest of the API — tweaking a prompt shouldn't require redeploying the login. Everything else — identity, monitoring, the classroom itself — stays together, because it still has no reason to leave.

Conclusion

FFmpeg didn't become a microservice because microservices are modern. It became one because it carried a binary of hundreds of megabytes and changed at a completely different pace from the API. Two concrete reasons, both measurable. CPU, which looked like the most obvious reason, was no reason at all: it's a question of where the service runs, and splitting only made that problem easier to solve when it shows up.
The rest of Davna stayed in the monolith by the same criterion, applied the other way around. And it's still organized: the modules have clear boundaries, explicit contracts and tests that go through everything in memory. When one of them has a concrete reason to leave, the boundary is already drawn — all that's left is to swap a function call for a network call, and to consciously accept everything that comes with it.
In the end, the question I ask isn't "monolith or microservice?". It's "what makes this piece different from the rest?". If the answer is "nothing", it stays where it is.
Photo of Yago Marinho
Yago MarinhoSenior Software Engineer. I build AI-powered products, from design to deploy.
See portfolio ↗