Ostrowski Maciej
A blog of the software engineering part of life

From Monolith to Microservice – An OCR Service Case Study

Why Do This?

You’re developing a new optical character recognition (OCR) feature — extracting text from photos. This functionality is an excellent candidate to be implemented as a supporting microservice for your main system. Thanks to this, you can scale it independently, both horizontally (by running additional instances) and vertically (by increasing resources on a single instance) according to current needs and load.

Instead of building the entire microservice infrastructure from scratch right away, you begin by creating it as another module inside your current monolith. Only then do you gradually extract it into a standalone microservice. This significantly simplifies and accelerates the whole process.

In this article, I walk you through the individual steps of this approach.

October 6, 2026

The starting point

The project is nowreading.club, a Laravel application built as a modular monolith with DDD. It has two kinds of modules:

  • organizing modules in src/*: namespaces with Domain / Application / Infrastructure layers that may import each other;

  • strictly isolated modules in src_iso/*: future microservices. Nothing outside may import their classes. The rest of the app talks to them only through contracts in src/Shared: a Query object, dispatched on Laravel's Bus, handled by a Handler inside the module.

An isolated module (src_iso/Bits) and a Bus adapter for it (BusBitReader) already existed. OCR was going to be the second isolated module and the first one to actually leave the monolith.

A note before we start: the whole solution, process described here was done by hand, really, without the help of any AI models back then! Today is October 6, 2026, and Claude is helping me reconstruct the events from the git history.

The map

The extraction took about two weeks. In every step the app kept working, and in every step one config value could switch back to the previous one.

StepWhat changesWhat it provesWhen
1. A seam with a stubInterface + Bus adapter + empty HTTP adapterCallers do not know where OCR lives2026-04-15
2. Real recognition, in-processTesseract, async job, signed webhookThe contract survives real, asynchronous work2026-04-17…19
3. HTTP, same processThe HTTP adapter calls the same appSerialization, signing, timeouts, error paths2026-04-19
4. A second containerSame image, APP_ROLE=ocrWhat breaks once there is a real network in between2026-04-25
5. A dedicated entry pointpublic/ocr.php, own bootstrap and provider listThe service boots only OCR2026-04-25
6. Own schema and migrationsocr connection, per-role migrationsThe service owns its data2026-04-26
7. Two production serversLaravel Cloud + Laravel ForgeIt runs in production2026-04-28…30

Step 1: Build the seam before the service

The first commit had no OCR in it at all. It had:

  • src_iso/Ocr with empty Domain / Application / Infrastructure folders;

  • a handler that returned a fixed string ("sample recognised text");

  • a contract in src/Shared: an interface and two implementations, BusOcrQuery (in-process) and DistributedBusOcrQuery (remote). The second one was an empty class;

  • a console command that called the interface and printed the result.

That empty DistributedBusOcrQuery was the most important file of the whole project. It put the place where the network will go into the code on day one, before there was any network to deal with. Every later step just filled it in.

This is the contract as it looks today. It is still small:

interface OcrQueryInterface
{
    public const string RECOGNIZE_PATH = '/api/██████████';

    public function recognizeText(string $ocrId, string $imagePath, string $webhookUrl): OcrAccepted;
}

The in-process adapter is a single Bus dispatch:

class BusOcrQuery implements OcrQueryInterface
{
    public function recognizeText(string $ocrId, string $imagePath, string $webhookUrl): OcrAccepted
    {
        /** @var OcrAccepted $ocrAccepted */
        $ocrAccepted = Bus::dispatch(new RecognizeTextQuery($ocrId, $imagePath, $webhookUrl));

        return $ocrAccepted;
    }
}

Step 2: Make it real, and make it asynchronous

Two modules, not one

OCR ended up as two modules with two different jobs:

src/Ocr (main app)src_iso/Ocr (the future service)
RoleThe app's record of each requestDoes the work: load, validate, recognise, report
Dataocrs table: status stored → reading → read / failed, text, errorIts own insert-only ocr_requests table, one row per finished attempt
ShapeNo aggregate on purpose: it is a record, not a business conceptA Domain made of ports: image loader, format validator, text recognizer, webhook client

Neither module calls the other. The app glues them together. The recognition module keeps its own table so it never has to reach back into the main app's database. Once it runs on a different server, it would not be able to anyway.

One round trip

 main app                                            OCR module / service
 ────────                                            ────────────────────
 ocrs row (ULID) ──► OcrQueryInterface ──bus/HTTP──► RecognizeTextQueryHandler
                                                       │ image exists?  → OcrAccepted (sync)
       ◄────────── OcrAccepted(success: true|false) ───┘
 mark READING / FAILED                                 │ queue RecognizeTextJob
                                                       ▼
                                                     load image from the shared bucket
                                                     validate format → Tesseract
 POST /webhooks/ocr  ◄──────── signed webhook ─────  send result, save attempt
 mark READ / FAILED

A few decisions in this step made every later step easier:

  • Pass a reference, not bytes. The main app uploads the image to object storage (MinIO locally, S3 in production) and sends only its path. Both sides point at the same bucket.

  • Answer fast, report later. The handler only checks that the image exists. It returns an acknowledgement right away and queues the real work. The result comes back by webhook.

  • One correlation key. Requests were first matched by image path, then by a UUID, and finally by the ULID of the ocrs row.

  • The caller sends the webhook URL. The service does not need to know where the main app lives.

The plan I did not build

The first design for the remote part was much bigger: SQS between the apps, a separate Laravel consumer on Forge reading raw SQS messages, a signed webhook back, and Reverb WebSockets to push the result to the browser. It came with a list of ten open questions: per-user vs per-job channels, presigned URLs, idempotency, retention, UI timeouts, and so on.

What survived from that plan: asynchronous processing, the signed webhook, and the shared object storage. What did not: SQS, Reverb, and a separate application. The remote call is a plain signed HTTP request, and the queue is Laravel's own (Redis + Horizon) on the OCR side.

Why drop them? For the sake of simplicity in the first version. The design can grow later, but only when there is a real need for it. For now, what is there is enough.

Choosing the engine

I also looked at the Laravel AI SDK with a vision model and at AWS Textract:

TesseractVision LLM via Laravel AI SDKAWS Textract
Cost per image~0 (worker CPU)$0.001–0.02$0.0015–0.05
Handwriting, tablesweakgoodvery good
Latency0.2–2 s2–10 s1–5 s
Text leaves your infrastructurenoyes (unless self-hosted)yes

Tesseract won for now (Polish + English language data). Behind the TextRecognizerInterface port, the engine is an Infrastructure detail. Switching to a vision model later changes one adapter, not the architecture.

Step 3: Go over HTTP without leaving the process

Before adding a second server, I added a network hop inside a single one. DistributedBusOcrQuery got a real implementation, and OCR_SERVICE_URL pointed at the application itself.

It sounds pointless, but it tests everything about the remote call that does not need real infrastructure: JSON serialization, request signing, timeouts, error handling, and switching the binding through config. Debugging those things with a single process is much easier than with two servers.

The OCR module exposes POST /api/██████████. Its controller dispatches the same RecognizeTextQuery on its own Bus, so both transports end up in one handler:

class RecognizeTextController
{
    public function __invoke(RecognizeTextRequest $request): JsonResponse
    {
        /** @var array{ocrId: string, imagePath: string, webhookUrl: string} $data */
        $data = $request->validated();

        /** @var OcrAccepted $accepted */
        $accepted = Bus::dispatch(new RecognizeTextQuery(
            ocrId: $data['ocrId'],
            imagePath: $data['imagePath'],
            webhookUrl: $data['webhookUrl'],
        ));

        return response()->json([
            'imagePath' => $accepted->imagePath,
            'success' => $accepted->success,
            'errorMessage' => $accepted->errorMessage,
        ]);
    }
}

The caller picks the transport with one config value:

$this->app->bind(OcrQueryInterface::class, function (Application $app): OcrQueryInterface {
    if (config('ocr.driver') === 'distributed') {
        return new DistributedBusOcrQuery(/* http client, signature, url, secret, timeout */);
    }

    return $app->make(BusOcrQuery::class);
});

Two design rules from this step:

  • A domain failure is still a successful HTTP call. "Image not found" returns 200 with success: false. Non-2xx codes are only for transport, authentication and server errors.

  • The HTTP adapter turns network failures into domain answers. A timeout, a non-2xx response or a malformed body becomes OcrAccepted(success: false, errorMessage: …). The caller has one code path, whether OCR runs in-process or two servers away.

Signing both directions with one class

Requests to the service and webhooks back use the same signature class:

class OcrWebhookSignature
{
    public function sign(string $body, string $secret): string
    {
        // ...
    }

    public function verify(string $header, string $body, string $secret, int $tolerance): bool
    {
        // ...
    }
}

The signature is an HMAC-SHA256 that covers both the body and a timestamp. It is compared in constant time, and signatures that are too old are rejected.

Step 4: A second container — let the network bite locally

Step 3 was still one process, one database and one queue. It proved nothing about real isolation. Step 4 added an ocr service to docker-compose.yml. It uses the same image and the same code, but has its own role and its own Horizon:

ocr:
    image: channels-8.5/app
    environment:
        APP_ROLE: ocr
        OCR_DRIVER: bus          # never call yourself over HTTP
        REDIS_DB: 2
        REDIS_CACHE_DB: 3
        HORIZON_PREFIX: 'channels_ocr_horizon:'
        HORIZON_NAME: ocr
        HORIZON_SUPERVISORS: supervisor-ocr

It found exactly the kind of problems that would have been painful to debug on a remote server:

  • The webhook URL was unreachable. The callback URL was built from APP_URL, a host-side domain that the OCR container could not resolve. The fix was a separate OCR_WEBHOOK_BASE_URL.

  • The service could call itself. The OCR container inherited OCR_DRIVER=distributed from .env. Any code path that resolved the interface would have sent an HTTP request back to the same container. The compose file now forces bus there.

  • Queues and Horizon dashboards mixed up. Each container got its own Redis databases, Horizon prefix and name, and a HORIZON_SUPERVISORS list. That list chooses which supervisors start on which machine.

  • A dev-server detour. Inside the container, artisan serve did not pass every environment variable to its request workers, so the Horizon dashboard showed Inactive. Running php -S with public/index.php as the router broke static assets, because Laravel's index.php never hands a file back to the built-in server. What worked was php -S with the router Laravel ships for artisan serve (Illuminate/Foundation/resources/server.php).

Step 5: How much of the monolith should the OCR server boot?

The same code now ran in a container meant only for OCR, but it still booted Filament, Livewire and the public site. There are three levels of "turning the rest off":

LevelWhat is in memory on the OCR serverRisk
1. Fence middleware: 404 for everything outside a whitelistThe whole appCode is unreachable, but still loaded
2. Selective providers: skip providers when APP_ROLE=ocrOnly OCR-related providersTwo lists to keep in sync; easy to miss a listener
3. A dedicated entry pointOnly what OCR's own bootstrap registersSome duplicated bootstrap code

I implemented level 2 first and replaced it with level 3 shortly after. The problem with level 2 is that it is runtime branching that fails open. If someone removes the APP_ROLE variable or forgets a branch, the OCR server quietly boots Filament again. Level 3 makes the separation structural: the OCR server has its own front controller, public/ocr.php, which loads its own bootstrap:

// bootstrap/ocr.php
return Application::configure(basePath: dirname(__DIR__))
    ->withProviders(require __DIR__ . '/providers-ocr.php', withBootstrapProviders: false)
    ->withRouting(
        commands: __DIR__ . '/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->append(SetLocale::class);
    })
    ->withExceptions(function (Exceptions $exceptions): void {})->create();
// bootstrap/providers-ocr.php
return [
    App\Providers\HorizonServiceProvider::class,
    Laravel\Nightwatch\NightwatchServiceProvider::class,
    OcrRecognition\Infrastructure\OcrProvider::class,
];

It has no web routes (only the /up health check) and no main-app providers or middleware, so there is nothing left to gate. One branch remains: artisan is shared, so it picks the bootstrap by role.

$bootstrap = getenv('APP_ROLE') === 'ocr' ? '/bootstrap/ocr.php' : '/bootstrap/app.php';

The duplicated bootstrap is the whole point. A change to the main app's bootstrap cannot leak into the service. On the day OCR moves to its own repository, these files move over 1:1.

Step 6: Data — one database, two schemas

The service had to own its data without needing a new database server on day one. The compromise: the same PostgreSQL instance and database, but an ocr schema reached through a separate ocr connection (search_path=ocr).

return new class extends Migration
{
    protected $connection = 'ocr';

    public function up(): void
    {
        DB::statement('CREATE SCHEMA IF NOT EXISTS ocr');

        Schema::create('ocr_requests', static function (Blueprint $table): void {
            // ...
        });
    }
};

The rules that make this safe:

  • Each server runs only its own migrations. OCR migrations are not auto-discovered outside tests. The OCR server runs them explicitly: php artisan migrate --database=ocr --path=src_iso/Ocr/Infrastructure/Persistence/migrations. Their history lands in ocr.migrations, so the two servers never race on one migrations table.

  • Create the schema idempotently, and never DROP SCHEMA … CASCADE. On a shared instance it may already hold data.

  • The default connection stays pgsql on both servers. Otherwise everything that uses the default connection would run against the wrong schema.

  • Tests need their own refresh trait. RefreshDatabase only refreshes the default schema.

Moving OCR to its own database later only means giving the ocr connection its own host variables. The module code and the migrations stay as they are.

Redis is shared too, but only for convenience of having one instance: main ↔ OCR traffic goes over HTTP and never depends on it. Object storage, on the other hand, must be shared, because the main app uploads and the service reads.

Step 7: Production — Laravel Cloud + Laravel Forge

The main app runs on Laravel Cloud (Octane on FrankenPHP). The OCR service runs on a Forge server that provides the Tesseract binary with its language data. By this point, production was mostly infrastructure work, not code. The rollout order kept every step reversible:

  1. Keep production on OCR_DRIVER=bus.

  2. Provision the OCR server and deploy the same repository with APP_ROLE=ocr. Nobody calls it yet.

  3. Check it from the outside: /up returns 200, / returns 404, and an unsigned POST /api/██████████ returns 401.

  4. Switch the main app to OCR_DRIVER=distributed and point OCR_SERVICE_URL at the service.

  5. Stop the main app's supervisor-ocr (HORIZON_SUPERVISORS=supervisor-1), so it no longer takes jobs from its old queue.

  6. Watch it for a while.

The OCR deploy skips everything that exists only for the main app's front end:

git pull
composer install --no-dev --no-scripts --optimize-autoloader --no-interaction
php artisan package:discover --ansi
php artisan horizon:publish
php artisan migrate --force --database=ocr --path=src_iso/Ocr/Infrastructure/Persistence/migrations
php artisan optimize
php artisan horizon:terminate

--no-scripts skips the Filament and Livewire asset hooks, and there is no npm run build, because the service never renders a Vite asset.

The route-cache trap

The first production deploy turned the OCR service back into the full web app. Opening / on the OCR host returned a 500 instead of a 404. The stack trace started in public/ocr.php but ended in the main app's HomeController, querying a channels table that does not exist in the OCR schema.

The cause was Forge's default deploy script. artisan route:cache compiled the main app's routes into bootstrap/cache/routes-v7.php, and that directory is shared by both entry points. Laravel loads a route cache file whenever one exists, so the OCR bootstrap served routes it never registered.

The fix: never run route:cache or view:cache on the OCR server (config and event caches are safe), then clear the bad cache once. Caching routes for an app with a single route gives you nothing anyway.

The last move: the caller picks the transport

On 2026-04-30 the interface binding moved out of the OCR module into the main app. It was a small commit, but it settled who owns what: the isolated module provides the handler, and the calling side decides how to reach it. The service no longer knows that OcrQueryInterface exists.

Lessons

  1. Build the seam first, while it is free. An empty DistributedBusOcrQuery on day one cost nothing. Every later step filled it in.

  2. Keep every step reversible with one config value. OCR_DRIVER=bus still works today.

  3. Add the network in layers. Same process over HTTP, then a second container, then a second server. Each layer finds a different class of bugs, and the cheap layers find them first.

  4. Make separation structural, not conditional. A separate entry point cannot be forgotten. An if (APP_ROLE) can.

  5. Send references, not payloads. A path in a shared bucket plus a webhook keeps both sides simple.

  6. Contracts must tell the truth. OcrData with empty text became OcrAccepted.

  7. Own your data from the start, even on a shared server. A separate schema, connection and migration history make the later move to a separate database a config change.

What's next

That is a f*cking good question! So many things has changed in programming industry that I still wonder what the next step should be for me... I've started to learn welding, really.

  • probably a mobile app