Strangler Fig: PHP Monolith to Go Service

Migrate a PHP monolith to Go using the Strangler Fig pattern with Traefik.

Introduction

30 Second Summary

Every growing company eventually hits the same wall: a monolith that started simple now has its domains tangled together through shared database tables. Splitting it apart feels like defusing a bomb, because cutting in the wrong place breaks data consistency across the whole system.

In this project, you will analyze a PHP monolith using a knowledge graph centered on data consistency boundaries, extract a domain into a clean Go microservice with hexagonal architecture, and wire up Traefik to shift traffic incrementally from the old to the new using the Strangler Fig pattern.

What You'll Build

A complete monolith-to-microservice extraction pipeline where a legacy PHP app and a new Go service coexist behind a single reverse proxy.

By the end of this project, you'll have:

  • A knowledge graph of a PHP monolith that maps code dependencies and data consistency boundaries to identify safe extraction points.
  • A new Go microservice structured with hexagonal architecture (domain, ports, adapters) and a SQLite persistence adapter.
  • A working Strangler Fig setup where Traefik routes /products to the new Go service and /orders to the legacy PHP monolith, running side by side in Docker Compose.
  • Secret Mission: add a health-check endpoint and automatic fallback routing so Traefik reroutes traffic to the PHP monolith if the Go service goes down.

Are there any prerequisites?

This project assumes you are comfortable with PHP, Go, Docker, and software architecture concepts. You should have Go and Docker installed on your machine before starting.

Build the PHP Monolith

Every modernization project starts with understanding the legacy system you are working with. In this project, you have a PHP monolith that serves two domains from a single codebase: Products and Orders.

The twist is that these domains are tangled together through shared database access. Products reads from the orders table to calculate available stock, and Orders reads from the products table to look up pricing.

In this step, get ready to:
  • Create a PHP monolith with tangled Products and Orders domains sharing a single SQLite database.
  • Containerize the monolith with Nginx, PHP-FPM, and Docker Compose.
  • Seed the database and test both API endpoints with curl.
Create the PHP monolith application

You are going to build a single PHP file that handles both the Products API (/products) and the Orders API (/orders).

Both domains read and write to the same SQLite database file. This cross-domain data coupling is exactly the kind of mess you will learn to untangle later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and its subdirectories by running these commands:
mkdir -p strangler-fig-migration/monolith/public
cd strangler-fig-migration
  • Confirm the directory structure exists by running:
ls monolith/public

The command should complete without errors, confirming the monolith/public folder exists.

Seeing 'No such file or directory'?

Make sure you ran the mkdir -p command first. The -p flag creates all nested directories at once.

Also check that you ran cd strangler-fig-migration before running ls.

Help me debug my directory structure

Now you will build the monolith's entry point file piece by piece. This single file handles all routing, database setup, and business logic for both domains.

  • Create the monolith/public/index.php file and add the database setup code by pasting the following:
<?php
declare(strict_types=1);

// Connect to the shared SQLite database
$dbPath = '/data/monolith.db';
$db = new SQLite3($dbPath);
$db->enableExceptions(true);
$db->exec('PRAGMA journal_mode = WAL');
$db->exec('PRAGMA foreign_keys = ON');

// Create tables if they don't exist
$db->exec('
    CREATE TABLE IF NOT EXISTS products (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        price REAL NOT NULL,
        stock INTEGER NOT NULL DEFAULT 0
    )
');
$db->exec('
    CREATE TABLE IF NOT EXISTS orders (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        product_id INTEGER NOT NULL,
        quantity INTEGER NOT NULL,
        total_price REAL NOT NULL,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (product_id) REFERENCES products(id)
    )
');

// Parse the incoming request
$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = rtrim($uri, '/');

header('Content-Type: application/json');

What does this code do?

  • Opens a SQLite database at /data/monolith.db. Both the Products and Orders domains share this single file.
  • Creates two tables, products and orders, if they do not already exist.
  • Parses the HTTP method and URI so the rest of the file can route requests to the right handler.
  • Save index.php.

You will not see any output yet. The next two chunks add the route handlers that make this file functional.

  • In monolith/public/index.php, add the Products domain routes below the header('Content-Type: application/json'); line:

// --- Products Domain (tangled: reads from orders for stock validation) ---
if (preg_match('#^/products(/([0-9]+))?$#', $uri, $matches)) {
    $productId = $matches[2] ?? null;

    // GET /products - list all products with available stock
    if ($method === 'GET' && $productId === null) {
        $result = $db->query('SELECT * FROM products');
        $products = [];
        while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
            // TANGLED: cross-domain read to calculate sold quantity
            $sold = $db->querySingle(
                "SELECT COALESCE(SUM(quantity), 0) FROM orders WHERE product_id = " . $row['id']
            );
            $row['available_stock'] = $row['stock'] - (int)$sold;
            $row['source'] = 'php-monolith';
            $products[] = $row;
        }
        echo json_encode(['data' => $products]);
        exit;
    }

    // GET /products/{id} - get a single product
    if ($method === 'GET' && $productId !== null) {
        $stmt = $db->prepare('SELECT * FROM products WHERE id = :id');
        $stmt->bindValue(':id', (int)$productId, SQLITE3_INTEGER);
        $row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
        if (!$row) {
            http_response_code(404);
            echo json_encode(['error' => 'Product not found']);
            exit;
        }
        $sold = $db->querySingle(
            "SELECT COALESCE(SUM(quantity), 0) FROM orders WHERE product_id = " . $row['id']
        );
        $row['available_stock'] = $row['stock'] - (int)$sold;
        $row['source'] = 'php-monolith';
        echo json_encode(['data' => $row]);
        exit;
    }

    // POST /products - create a new product
    if ($method === 'POST' && $productId === null) {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!$input || !isset($input['name'], $input['price'], $input['stock'])) {
            http_response_code(400);
            echo json_encode(['error' => 'Missing required fields: name, price, stock']);
            exit;
        }
        $stmt = $db->prepare('INSERT INTO products (name, price, stock) VALUES (:name, :price, :stock)');
        $stmt->bindValue(':name', $input['name'], SQLITE3_TEXT);
        $stmt->bindValue(':price', (float)$input['price'], SQLITE3_FLOAT);
        $stmt->bindValue(':stock', (int)$input['stock'], SQLITE3_INTEGER);
        $stmt->execute();
        $id = $db->lastInsertRowID();
        http_response_code(201);
        echo json_encode(['data' => ['id' => $id, 'name' => $input['name'], 'price' => (float)$input['price'], 'stock' => (int)$input['stock'], 'source' => 'php-monolith']]);
        exit;
    }
}

Why does Products read from the orders table?

Look at the lines marked TANGLED. To calculate available_stock, the Products handler queries the orders table to find out how many units have been sold.

This is a cross-domain data dependency. Products cannot calculate its own stock without reaching into Orders territory. This kind of coupling is what makes monoliths hard to split.

  • Save index.php.

You will not see a change yet. The next chunk adds the Orders domain and seed endpoint.

  • Add the Orders domain, seed endpoint, and 404 fallback below the closing } of the Products block:

// --- Orders Domain (tangled: reads from products for pricing) ---
if (preg_match('#^/orders(/([0-9]+))?$#', $uri, $matches)) {
    $orderId = $matches[2] ?? null;

    // GET /orders - list all orders
    if ($method === 'GET' && $orderId === null) {
        $result = $db->query('SELECT * FROM orders');
        $orders = [];
        while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
            $orders[] = $row;
        }
        echo json_encode(['data' => $orders, 'source' => 'php-monolith']);
        exit;
    }

    // POST /orders - create a new order
    if ($method === 'POST' && $orderId === null) {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!$input || !isset($input['product_id'], $input['quantity'])) {
            http_response_code(400);
            echo json_encode(['error' => 'Missing required fields: product_id, quantity']);
            exit;
        }
        // TANGLED: cross-domain read to get product price
        $product = $db->querySingle(
            "SELECT price, stock FROM products WHERE id = " . $input['product_id'],
            true
        );
        if (!$product) {
            http_response_code(404);
            echo json_encode(['error' => 'Product not found']);
            exit;
        }
        $totalPrice = (float)$product['price'] * (int)$input['quantity'];
        $stmt = $db->prepare('INSERT INTO orders (product_id, quantity, total_price) VALUES (:pid, :qty, :total)');
        $stmt->bindValue(':pid', (int)$input['product_id'], SQLITE3_INTEGER);
        $stmt->bindValue(':qty', (int)$input['quantity'], SQLITE3_INTEGER);
        $stmt->bindValue(':total', $totalPrice, SQLITE3_FLOAT);
        $stmt->execute();
        $id = $db->lastInsertRowID();
        http_response_code(201);
        echo json_encode(['data' => ['id' => $id, 'product_id' => (int)$input['product_id'], 'quantity' => (int)$input['quantity'], 'total_price' => $totalPrice]]);
        exit;
    }
}

// --- Seed endpoint: populates the database with sample data ---
if ($uri === '/seed' && $method === 'POST') {
    $db->exec("INSERT OR IGNORE INTO products (id, name, price, stock) VALUES (1, 'Widget A', 29.99, 100)");
    $db->exec("INSERT OR IGNORE INTO products (id, name, price, stock) VALUES (2, 'Widget B', 49.99, 50)");
    $db->exec("INSERT OR IGNORE INTO products (id, name, price, stock) VALUES (3, 'Gadget C', 99.99, 25)");
    $db->exec("INSERT OR IGNORE INTO orders (id, product_id, quantity, total_price) VALUES (1, 1, 3, 89.97)");
    $db->exec("INSERT OR IGNORE INTO orders (id, product_id, quantity, total_price) VALUES (2, 2, 1, 49.99)");
    echo json_encode(['message' => 'Database seeded']);
    exit;
}

// Fallback for unmatched routes
http_response_code(404);
echo json_encode(['error' => 'Not found', 'uri' => $uri, 'method' => $method]);

What does this code do?

  • The Orders handler creates orders by reading the product price directly from the products table. This is the second cross-domain coupling, marked TANGLED in the code.
  • The /seed endpoint inserts sample products and orders so you can test the API without manual data entry.
  • The 404 fallback at the bottom catches any unmatched routes and returns a JSON error.
  • Save index.php.

Seeing a syntax error highlight in your editor?

Make sure the opening <?php tag is on the very first line of the file with no blank lines or spaces before it.

Also check that you have matching opening and closing braces for each if block.

Help me fix a syntax error in my index.php

✔️ Awesome, I've got everything!

Double check you have saved monolith/public/index.php.

ⓧ I'd like to double check the full code

<?php
declare(strict_types=1);

$dbPath = '/data/monolith.db';
$db = new SQLite3($dbPath);
$db->enableExceptions(true);
$db->exec('PRAGMA journal_mode = WAL');
$db->exec('PRAGMA foreign_keys = ON');

$db->exec('
    CREATE TABLE IF NOT EXISTS products (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        price REAL NOT NULL,
        stock INTEGER NOT NULL DEFAULT 0
    )
');
$db->exec('
    CREATE TABLE IF NOT EXISTS orders (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        product_id INTEGER NOT NULL,
        quantity INTEGER NOT NULL,
        total_price REAL NOT NULL,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (product_id) REFERENCES products(id)
    )
');

$method = $_SERVER['REQUEST_METHOD'];
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = rtrim($uri, '/');

header('Content-Type: application/json');

if (preg_match('#^/products(/([0-9]+))?$#', $uri, $matches)) {
    $productId = $matches[2] ?? null;

    if ($method === 'GET' && $productId === null) {
        $result = $db->query('SELECT * FROM products');
        $products = [];
        while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
            $sold = $db->querySingle(
                "SELECT COALESCE(SUM(quantity), 0) FROM orders WHERE product_id = " . $row['id']
            );
            $row['available_stock'] = $row['stock'] - (int)$sold;
            $row['source'] = 'php-monolith';
            $products[] = $row;
        }
        echo json_encode(['data' => $products]);
        exit;
    }

    if ($method === 'GET' && $productId !== null) {
        $stmt = $db->prepare('SELECT * FROM products WHERE id = :id');
        $stmt->bindValue(':id', (int)$productId, SQLITE3_INTEGER);
        $row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
        if (!$row) {
            http_response_code(404);
            echo json_encode(['error' => 'Product not found']);
            exit;
        }
        $sold = $db->querySingle(
            "SELECT COALESCE(SUM(quantity), 0) FROM orders WHERE product_id = " . $row['id']
        );
        $row['available_stock'] = $row['stock'] - (int)$sold;
        $row['source'] = 'php-monolith';
        echo json_encode(['data' => $row]);
        exit;
    }

    if ($method === 'POST' && $productId === null) {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!$input || !isset($input['name'], $input['price'], $input['stock'])) {
            http_response_code(400);
            echo json_encode(['error' => 'Missing required fields: name, price, stock']);
            exit;
        }
        $stmt = $db->prepare('INSERT INTO products (name, price, stock) VALUES (:name, :price, :stock)');
        $stmt->bindValue(':name', $input['name'], SQLITE3_TEXT);
        $stmt->bindValue(':price', (float)$input['price'], SQLITE3_FLOAT);
        $stmt->bindValue(':stock', (int)$input['stock'], SQLITE3_INTEGER);
        $stmt->execute();
        $id = $db->lastInsertRowID();
        http_response_code(201);
        echo json_encode(['data' => ['id' => $id, 'name' => $input['name'], 'price' => (float)$input['price'], 'stock' => (int)$input['stock'], 'source' => 'php-monolith']]);
        exit;
    }
}

if (preg_match('#^/orders(/([0-9]+))?$#', $uri, $matches)) {
    $orderId = $matches[2] ?? null;

    if ($method === 'GET' && $orderId === null) {
        $result = $db->query('SELECT * FROM orders');
        $orders = [];
        while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
            $orders[] = $row;
        }
        echo json_encode(['data' => $orders, 'source' => 'php-monolith']);
        exit;
    }

    if ($method === 'POST' && $orderId === null) {
        $input = json_decode(file_get_contents('php://input'), true);
        if (!$input || !isset($input['product_id'], $input['quantity'])) {
            http_response_code(400);
            echo json_encode(['error' => 'Missing required fields: product_id, quantity']);
            exit;
        }
        $product = $db->querySingle(
            "SELECT price, stock FROM products WHERE id = " . $input['product_id'],
            true
        );
        if (!$product) {
            http_response_code(404);
            echo json_encode(['error' => 'Product not found']);
            exit;
        }
        $totalPrice = (float)$product['price'] * (int)$input['quantity'];
        $stmt = $db->prepare('INSERT INTO orders (product_id, quantity, total_price) VALUES (:pid, :qty, :total)');
        $stmt->bindValue(':pid', (int)$input['product_id'], SQLITE3_INTEGER);
        $stmt->bindValue(':qty', (int)$input['quantity'], SQLITE3_INTEGER);
        $stmt->bindValue(':total', $totalPrice, SQLITE3_FLOAT);
        $stmt->execute();
        $id = $db->lastInsertRowID();
        http_response_code(201);
        echo json_encode(['data' => ['id' => $id, 'product_id' => (int)$input['product_id'], 'quantity' => (int)$input['quantity'], 'total_price' => $totalPrice]]);
        exit;
    }
}

if ($uri === '/seed' && $method === 'POST') {
    $db->exec("INSERT OR IGNORE INTO products (id, name, price, stock) VALUES (1, 'Widget A', 29.99, 100)");
    $db->exec("INSERT OR IGNORE INTO products (id, name, price, stock) VALUES (2, 'Widget B', 49.99, 50)");
    $db->exec("INSERT OR IGNORE INTO products (id, name, price, stock) VALUES (3, 'Gadget C', 99.99, 25)");
    $db->exec("INSERT OR IGNORE INTO orders (id, product_id, quantity, total_price) VALUES (1, 1, 3, 89.97)");
    $db->exec("INSERT OR IGNORE INTO orders (id, product_id, quantity, total_price) VALUES (2, 2, 1, 49.99)");
    echo json_encode(['message' => 'Database seeded']);
    exit;
}

http_response_code(404);
echo json_encode(['error' => 'Not found', 'uri' => $uri, 'method' => $method]);
Containerize the monolith with Docker

The PHP file is ready, but it needs a web server and a runtime to serve requests. You will package Nginx as the web server and PHP-FPM as the runtime into a single Docker container.

  • Create the Nginx server configuration by adding this to a new file at monolith/nginx.conf:
server {
    listen 80;
    server_name _;
    root /app/public;
    index index.php;

    # Send all requests to index.php if no matching file exists
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Pass PHP files to PHP-FPM for processing
    location ~ \.php$ {
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

What does this config do?

  • Nginx listens on port 80 and serves files from /app/public.
  • The try_files directive routes all requests to index.php when no static file matches. This makes the PHP file act as a front controller.
  • PHP files are passed to PHP-FPM on port 9000 for execution.
  • Save monolith/nginx.conf.
  • Create the Dockerfile that packages Nginx and PHP-FPM together by adding this to a new file at monolith/Dockerfile:
FROM php:8.4-fpm-alpine

# Install Nginx and SQLite development headers, then enable the PDO SQLite extension
RUN apk add --no-cache nginx sqlite-dev \
    && docker-php-ext-install pdo_sqlite

# Copy server config and application code into the image
COPY nginx.conf /etc/nginx/http.d/default.conf
COPY public/ /app/public/

# Create the data directory for the SQLite database
RUN mkdir -p /data && chown www-data:www-data /data

EXPOSE 80

# Start PHP-FPM in the background, then Nginx in the foreground
CMD ["sh", "-c", "php-fpm -D && nginx -g 'daemon off;'"]

What does this Dockerfile do?

  • Starts from the official php:8.4-fpm-alpine image, which includes PHP-FPM on a lightweight Alpine Linux base.
  • Installs Nginx and the SQLite extension so the container can serve HTTP and query the database.
  • The CMD line starts PHP-FPM as a background daemon, then runs Nginx in the foreground to keep the container alive.
  • Save monolith/Dockerfile.
  • Create the Docker Compose configuration by adding this to a new file at docker-compose.yml in the root of your strangler-fig-migration folder:
services:
  monolith:
    build: ./monolith
    ports:
      - "80:80"
    volumes:
      - monolith-data:/data

volumes:
  monolith-data:

What does this compose file do?

  • Defines a single service called monolith that builds from the ./monolith directory.
  • Maps port 80 on your machine to port 80 inside the container so you can reach the API at localhost.
  • Uses a named volume (monolith-data) so the SQLite database persists across container restarts.
  • Save docker-compose.yml.
  • Build and start the monolith container by running this command from your strangler-fig-migration folder:
docker compose up --build -d

What does this command do?

  • --build tells Docker to build the image from the Dockerfile before starting.
  • -d runs the container in detached mode so your terminal stays free.
  • Confirm the container is running by running:
docker compose ps

You should see the monolith service listed with a status of Up and port 80 mapped.

Container not starting or exiting immediately?

Check the container logs for error details by running:

docker compose logs monolith

If you see address already in use, another process is using port 80. Stop it or change the port mapping in docker-compose.yml to 8081:80.

If you see a build error, make sure your monolith/Dockerfile, monolith/nginx.conf, and monolith/public/index.php all exist in the correct locations.

Help me debug my Docker Compose setup

Seed the database and test the API

The container is running, but the database is empty. You will hit the /seed endpoint to populate it with sample data, then query both domains to confirm they work.

  • Seed the database with sample products and orders by running:
curl -s -X POST http://localhost/seed | python3 -m json.tool

You should see {"message": "Database seeded"} in the output.

Seeing 'Connection refused'?

Run docker compose ps to make sure the monolith container is still running. If it has exited, check the logs with docker compose logs monolith.

Help me debug a connection refused error when curling my PHP monolith

  • Test the Products endpoint by running:
curl -s http://localhost/products | python3 -m json.tool

You should see a JSON response with three products. Each product includes an available_stock field and "source": "php-monolith".

Why does the response include a source field?

The source field tells you which service handled the request. Right now everything says php-monolith. Later, when the Go service takes over the Products endpoint, you will see go-products-service instead. This is how you will confirm the Strangler Fig cutover is working.

  • Test the Orders endpoint by running:
curl -s http://localhost/orders | python3 -m json.tool

You should see two orders in the response. Notice that each order has a total_price that was calculated by reading the product's price from the products table.

  • Verify the cross-domain tangling by fetching a single product by ID:
curl -s http://localhost/products/1 | python3 -m json.tool

Widget A should show "stock": 100 and "available_stock": 97. The difference of 3 comes from the order that bought 3 units of Widget A.

What is the tangling you keep seeing?

Products calculates available_stock by reaching into the orders table to sum sold quantities. Orders calculates total_price by reaching into the products table to look up the price.

Both domains have cross-domain reads. In the next step, you will analyze these data dependencies to decide which domain is safest to extract first.

Your PHP monolith is running with two tangled domains sharing a database. Next up, you will analyze these dependencies to build a knowledge graph that tells you exactly where the safe extraction points are.

Analyze the Monolith and Build the Knowledge Graph

Your PHP monolith is running, serving both Products and Orders from a single codebase and a shared SQLite database. Now comes the critical question: where exactly do you cut?

Code dependency tools can map which classes call which, but they miss something crucial. Two modules can have zero code dependencies yet share a database table that makes splitting them dangerous. In this step, you will build a knowledge graph that layers data consistency analysis on top of code coupling to reveal the safe extraction points.

In this step, get ready to:
  • Run dePHPend to analyze code-level dependencies in the monolith.
  • Map data consistency boundaries by tracing cross-domain database reads and writes.
  • Document the extraction decision and plan in a knowledge graph.
Run dePHPend to analyze code dependencies

dePHPend is a static analysis tool that examines PHP source code for class-level dependencies. You will run it via its official Docker image against the monolith source.

  • Generate a text dependency list by running this command:
docker run --rm -v $(pwd)/monolith/public:/inspect mihaeu/dephpend:latest text /inspect

What does this command do?

This pulls the mihaeu/dephpend:latest Docker image and mounts your monolith/public/ directory at /inspect inside the container.

The text command tells dePHPend to output a plain-text list of class-to-class dependencies it finds in the files under /inspect.

You should see little to no output. That is expected.

  • Now generate a Dependency Structure Matrix (DSM) by running:
docker run --rm -v $(pwd)/monolith/public:/inspect mihaeu/dephpend:latest dsm /inspect

Again, the output is minimal. The entire monolith lives in a single procedural file, index.php, with no PHP classes. dePHPend maps class-level coupling, so it has nothing to report here.

Why is the output empty?

Code dependency tools analyze which classes, functions, and files call each other. When everything is in one file, there are no cross-file dependencies to detect.

This is exactly why code coupling analysis alone is not enough. The Products and Orders domains in index.php share a database and query each other's tables. That data coupling is invisible to dePHPend. You need to trace it manually.

Seeing a parser error from dePHPend?

Some PHP constructs can cause dePHPend's parser to fail. Make sure you are mounting the correct directory (monolith/public, not monolith/).

If the error persists, the analysis result is the same: a single procedural file with no class-level dependencies. You can safely move on to the data consistency layer.

Why is dePHPend failing on my monolith code?

Map the data consistency boundaries

Code coupling told you nothing useful. The real coupling is in the data layer. You need to trace which PHP code writes to which tables, and which code reads across domain boundaries.

  • Open monolith/public/index.php and look for the Products domain section.
  • Find the lines where the Products code queries the orders table:
$sold = $db->querySingle(
    "SELECT COALESCE(SUM(quantity), 0) FROM orders WHERE product_id = " . $row['id']
);

This is a cross-domain read. The Products code reaches into the orders table to calculate available_stock by summing sold quantities.

  • Now find the Orders domain section. Look for where Orders queries the products table:
$product = $db->querySingle(
    "SELECT price, stock FROM products WHERE id = " . $input['product_id'],
    true
);

This is the other cross-domain read. The Orders code reads product pricing to calculate total_price when creating an order.

Why data consistency matters more than code coupling

Two modules can have zero code dependencies but share a database table with transactional consistency requirements. That shared mutable state makes them impossible to split without accepting eventual consistency.

The knowledge graph approach layers data ownership on top of code coupling. The critical question is not "which classes call each other" but "which modules share mutable state?"

Now you have the full picture. Time to record it.

  • Create a new file called knowledge-graph.md in your project root (the same folder that contains docker-compose.yml and the monolith/ folder).
  • Add the code dependency layer summary and the data consistency table by pasting the following:
# Monolith Knowledge Graph

## Code Dependency Layer (dePHPend output)
- Single file: public/index.php
- All logic co-located; no class-level separation

## Data Consistency Layer

| Module   | Tables Owned | Cross-Domain Data Access                        |
|----------|--------------|-------------------------------------------------|
| Products | products     | READ from orders (SUM quantity for stock calc)  |
| Orders   | orders       | READ from products (price for total_price calc) |

What does this knowledge graph capture?

  • The Code Dependency Layer summarizes the dePHPend findings: everything is in one file, so there are no class-level dependencies to report.
  • The Data Consistency Layer maps each module to the tables it owns and the cross-domain reads it performs. These cross-domain reads are your consistency boundaries.
  • Save knowledge-graph.md.
Document the extraction decision

The knowledge graph reveals an asymmetry. Products writes to the products table and reads from orders. Orders writes to the orders table and reads from products.

The key insight is directionality. No code in the Orders domain writes into the products table. That means extracting Products will not break any write path that Orders depends on.

What about the cross-domain read?

The Products code currently reads from orders to calculate available_stock (total stock minus sold quantity). Once Products lives in its own service with its own database, it can no longer query the orders table directly.

The solution is to accept eventual consistency. The Go service will return the stock field directly without deducting live order quantities. This trade-off is acceptable because the stock value is still accurate at write time, just not adjusted in real-time for orders.

  • Open knowledge-graph.md and add the extraction decision and plan below the Data Consistency Layer table:

## Extraction Decision
- Products domain: safe to extract
  - No inbound writes from Orders into products table
  - Outbound read from orders (available_stock) replaced by eventual consistency (use stock field directly)
- Orders domain: remains in monolith for now (reads product price; extraction requires Products API to be stable first)

## Extraction Plan
1. Build Go Products service with own SQLite database
2. Seed Go service with same product data
3. Route /products to Go service via Traefik
4. Route /orders to PHP monolith via Traefik

What does the extraction plan look like?

  • The Extraction Decision documents why Products is safe to extract (no inbound writes) and why Orders stays in the monolith for now (it depends on product pricing, so it needs a stable Products API first).
  • The Extraction Plan outlines the four concrete steps you will follow. The new Go service gets its own SQLite database, and Traefik handles traffic routing between old and new.
  • Save knowledge-graph.md.
  • Confirm the file now has four sections: Code Dependency Layer, Data Consistency Layer, Extraction Decision, and Extraction Plan.

✔️ Awesome, I've got everything!

Double check you have saved knowledge-graph.md with all four sections.

ⓧ I'd like to double check the full code

Here is the complete knowledge-graph.md file:

# Monolith Knowledge Graph

## Code Dependency Layer (dePHPend output)
- Single file: public/index.php
- All logic co-located; no class-level separation

## Data Consistency Layer

| Module   | Tables Owned | Cross-Domain Data Access                        |
|----------|--------------|-------------------------------------------------|
| Products | products     | READ from orders (SUM quantity for stock calc)  |
| Orders   | orders       | READ from products (price for total_price calc) |

## Extraction Decision
- Products domain: safe to extract
  - No inbound writes from Orders into products table
  - Outbound read from orders (available_stock) replaced by eventual consistency (use stock field directly)
- Orders domain: remains in monolith for now (reads product price; extraction requires Products API to be stable first)

## Extraction Plan
1. Build Go Products service with own SQLite database
2. Seed Go service with same product data
3. Route /products to Go service via Traefik
4. Route /orders to PHP monolith via Traefik

Your knowledge graph is complete. You know exactly where to cut and what trade-off to accept. Next up, you will build the Go Products service using hexagonal architecture with clean ports and adapters.

Extract the Products Domain into a Hexagonal Go Service

Your knowledge graph gave you a clear extraction target: the Products domain has no inbound writes from Orders, making it safe to pull out.

Now you will build the replacement service in Go using hexagonal architecture. This architecture separates your business logic from infrastructure concerns like databases and HTTP servers. The domain layer has zero knowledge of the outside world, and adapters plug in through well-defined interfaces called ports.

In this step, get ready to:
  • Scaffold a Go project with hexagonal architecture directories and install the pure-Go SQLite driver.
  • Implement the domain layer, port interfaces, and both inbound and outbound adapters.
  • Wire the composition root in main.go, add a Dockerfile, and verify the service responds to requests.
Scaffold the Go project with hexagonal architecture

Hexagonal architecture organizes code into three layers. The domain layer contains business logic with zero infrastructure imports. Ports are interfaces that define how the domain talks to the outside world. Adapters implement those interfaces for specific technologies like SQLite or HTTP.

  • Create the products-service directory structure by running these commands from your strangler-fig-migration project folder:
mkdir -p products-service/cmd/api
mkdir -p products-service/internal/domain
mkdir -p products-service/internal/port
mkdir -p products-service/internal/adapter/sqlite
mkdir -p products-service/internal/adapter/httphandler

What does this directory structure mean?

  • cmd/api/ is the composition root where you wire everything together.
  • internal/domain/ holds pure business logic with no infrastructure imports.
  • internal/port/ defines interfaces (contracts) that adapters must implement.
  • internal/adapter/sqlite/ and internal/adapter/httphandler/ are outbound and inbound adapters.
  • Confirm the directories were created by running:
find products-service -type d

You should see all six directories listed in the output.

Next, you need to initialize the Go module and install the SQLite driver. The modernc.org/sqlite package is a pure-Go SQLite driver that avoids CGO, which simplifies Docker builds.

  • Move into the products-service directory by running:
cd products-service
  • Initialize the Go module by running:
go mod init products-service
  • Install the pure-Go SQLite driver by running:
go get modernc.org/sqlite

You should see Go download the module and its transitive dependencies. Your go.mod now has a require block, and a go.sum file has been created.

Seeing "go: module not found"?

Make sure you are inside the products-service directory, not the project root. Run pwd to confirm your current path ends with products-service.

Also verify you ran go mod init products-service first. The go get command requires a go.mod file to exist.

Help me debug this go get error

Why a pure-Go SQLite driver?

The modernc.org/sqlite driver compiles without CGO. This means your Docker multi-stage build does not need gcc or a C toolchain, keeping the final image small and the build fast.

Implement the domain layer, ports, and adapters

You will build the service from the inside out. Start with the domain (pure business logic), then define the port interfaces, and finally implement the adapters that connect the domain to SQLite and HTTP.

The domain layer has zero infrastructure imports. If you see an import for database/sql or net/http in a domain file, something has gone wrong.

  • Create the Product entity with validation rules by adding this code to internal/domain/product.go:
package domain

import (
	"errors"
	"fmt"
)

// Product is the core domain entity
type Product struct {
	ID    int64   `json:"id"`
	Name  string  `json:"name"`
	Price float64 `json:"price"`
	Stock int     `json:"stock"`
}

// Sentinel errors for domain-level failure cases
var (
	ErrProductNotFound = errors.New("product not found")
	ErrInvalidProduct  = errors.New("invalid product")
)

// NewProduct validates inputs before creating a Product
func NewProduct(name string, price float64, stock int) (*Product, error) {
	if name == "" {
		return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidProduct)
	}
	if price <= 0 {
		return nil, fmt.Errorf("%w: price must be positive", ErrInvalidProduct)
	}
	if stock < 0 {
		return nil, fmt.Errorf("%w: stock cannot be negative", ErrInvalidProduct)
	}
	return &Product{
		Name:  name,
		Price: price,
		Stock: stock,
	}, nil
}

What does this code do?

  • Product is the core entity with JSON tags for serialization.
  • ErrProductNotFound and ErrInvalidProduct are sentinel errors that adapters can check with errors.Is() to decide HTTP status codes.
  • NewProduct() enforces business rules before a Product can exist. Notice there are no database or HTTP imports here.
  • Save product.go.

✔️ Awesome, I've got everything!

Great. Make sure you have saved product.go before moving on.

ⓧ I'd like to double check the full code

package domain

import (
	"errors"
	"fmt"
)

type Product struct {
	ID    int64   `json:"id"`
	Name  string  `json:"name"`
	Price float64 `json:"price"`
	Stock int     `json:"stock"`
}

var (
	ErrProductNotFound = errors.New("product not found")
	ErrInvalidProduct  = errors.New("invalid product")
)

func NewProduct(name string, price float64, stock int) (*Product, error) {
	if name == "" {
		return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidProduct)
	}
	if price <= 0 {
		return nil, fmt.Errorf("%w: price must be positive", ErrInvalidProduct)
	}
	if stock < 0 {
		return nil, fmt.Errorf("%w: stock cannot be negative", ErrInvalidProduct)
	}
	return &Product{
		Name:  name,
		Price: price,
		Stock: stock,
	}, nil
}

The domain also needs a service layer that coordinates business operations. In hexagonal architecture, the domain owns the repository interface. This keeps the dependency arrow pointing inward.

  • Create the domain service and repository interface by adding this code to internal/domain/service.go:
package domain

// ProductRepository defines what the domain needs from persistence.
// The domain owns this interface, not the adapter.
type ProductRepository interface {
	FindAll() ([]Product, error)
	FindByID(id int64) (*Product, error)
	Create(p *Product) error
}

// ProductService orchestrates domain operations
type ProductService struct {
	repo ProductRepository
}

// NewProductService creates a service with an injected repository
func NewProductService(repo ProductRepository) *ProductService {
	return &ProductService{repo: repo}
}

func (s *ProductService) ListProducts() ([]Product, error) {
	return s.repo.FindAll()
}

func (s *ProductService) GetProduct(id int64) (*Product, error) {
	return s.repo.FindByID(id)
}

// CreateProduct validates via NewProduct, then persists
func (s *ProductService) CreateProduct(name string, price float64, stock int) (*Product, error) {
	p, err := NewProduct(name, price, stock)
	if err != nil {
		return nil, err
	}
	if err := s.repo.Create(p); err != nil {
		return nil, err
	}
	return p, nil
}

Why does the domain own the repository interface?

This is the dependency inversion principle at work. The domain defines WHAT it needs (FindAll, FindByID, Create) without knowing HOW it happens. The SQLite adapter will implement this interface, but the domain never imports anything from the adapter package.

CreateProduct() calls NewProduct() first to enforce business rules before touching the database. Validation always happens in the domain, never in the adapter.

  • Save service.go.

✔️ Awesome, I've got everything!

Great. Make sure you have saved service.go before moving on.

ⓧ I'd like to double check the full code

package domain

type ProductRepository interface {
	FindAll() ([]Product, error)
	FindByID(id int64) (*Product, error)
	Create(p *Product) error
}

type ProductService struct {
	repo ProductRepository
}

func NewProductService(repo ProductRepository) *ProductService {
	return &ProductService{repo: repo}
}

func (s *ProductService) ListProducts() ([]Product, error) {
	return s.repo.FindAll()
}

func (s *ProductService) GetProduct(id int64) (*Product, error) {
	return s.repo.FindByID(id)
}

func (s *ProductService) CreateProduct(name string, price float64, stock int) (*Product, error) {
	p, err := NewProduct(name, price, stock)
	if err != nil {
		return nil, err
	}
	if err := s.repo.Create(p); err != nil {
		return nil, err
	}
	return p, nil
}

Now you need the inbound port. This interface defines what the outside world (the HTTP adapter) can ask of the application.

  • Define the inbound port by adding this code to internal/port/ports.go:
package port

import "products-service/internal/domain"

// InboundPort defines what the outside world can ask of the application.
type ProductServicePort interface {
	ListProducts() ([]domain.Product, error)
	GetProduct(id int64) (*domain.Product, error)
	CreateProduct(name string, price float64, stock int) (*domain.Product, error)
}

// OutboundPort defines what the application needs from infrastructure.
// This is already defined as domain.ProductRepository to keep
// the dependency direction correct (domain owns the interface).

What does this code do?

ProductServicePort is the inbound port. The HTTP handler will depend on this interface instead of the concrete ProductService struct. This means you could swap out the service implementation without changing the handler.

The outbound port (repository) lives in the domain package because the domain owns that contract. The comment in this file explains why it is not duplicated here.

  • Save ports.go.

✔️ Awesome, I've got everything!

Great. Make sure you have saved ports.go before moving on.

ⓧ I'd like to double check the full code

package port

import "products-service/internal/domain"

// InboundPort defines what the outside world can ask of the application.
type ProductServicePort interface {
	ListProducts() ([]domain.Product, error)
	GetProduct(id int64) (*domain.Product, error)
	CreateProduct(name string, price float64, stock int) (*domain.Product, error)
}

// OutboundPort defines what the application needs from infrastructure.
// This is already defined as domain.ProductRepository to keep
// the dependency direction correct (domain owns the interface).

With the domain and ports defined, you can now build the outbound adapter. This is the SQLite repository that implements the ProductRepository interface from the domain.

  • Create the SQLite repository adapter by adding this code to internal/adapter/sqlite/repository.go:
package sqlite

import (
	"database/sql"

	"products-service/internal/domain"
)

// ProductRepository implements domain.ProductRepository using SQLite
type ProductRepository struct {
	db *sql.DB
}

// NewProductRepository creates the products table and seeds initial data
func NewProductRepository(db *sql.DB) (*ProductRepository, error) {
	_, err := db.Exec(`
		CREATE TABLE IF NOT EXISTS products (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			name TEXT NOT NULL,
			price REAL NOT NULL,
			stock INTEGER NOT NULL DEFAULT 0
		)
	`)
	if err != nil {
		return nil, err
	}

	// Seed with the same products as the PHP monolith
	_, err = db.Exec(`
		INSERT OR IGNORE INTO products (id, name, price, stock) VALUES
		(1, 'Widget A', 29.99, 100),
		(2, 'Widget B', 49.99, 50),
		(3, 'Gadget C', 99.99, 25)
	`)
	if err != nil {
		return nil, err
	}

	return &ProductRepository{db: db}, nil
}

What does this code do?

  • NewProductRepository() creates the products table if it does not exist, then seeds three products matching the monolith data.
  • INSERT OR IGNORE means the seed data is only inserted on first run. Restarting the service will not create duplicates.
  • This adapter imports database/sql (infrastructure). The domain package never does.

The repository also needs its query methods. These implement the three operations the domain's ProductRepository interface requires.

  • Add the query methods below the NewProductRepository function in repository.go:
// FindAll returns every product in the database
func (r *ProductRepository) FindAll() ([]domain.Product, error) {
	rows, err := r.db.Query("SELECT id, name, price, stock FROM products")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var products []domain.Product
	for rows.Next() {
		var p domain.Product
		if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock); err != nil {
			return nil, err
		}
		products = append(products, p)
	}
	return products, rows.Err()
}

// FindByID returns a single product or ErrProductNotFound
func (r *ProductRepository) FindByID(id int64) (*domain.Product, error) {
	var p domain.Product
	err := r.db.QueryRow("SELECT id, name, price, stock FROM products WHERE id = ?", id).
		Scan(&p.ID, &p.Name, &p.Price, &p.Stock)
	if err == sql.ErrNoRows {
		return nil, domain.ErrProductNotFound
	}
	if err != nil {
		return nil, err
	}
	return &p, nil
}

// Create inserts a new product and sets the generated ID
func (r *ProductRepository) Create(p *domain.Product) error {
	result, err := r.db.Exec(
		"INSERT INTO products (name, price, stock) VALUES (?, ?, ?)",
		p.Name, p.Price, p.Stock,
	)
	if err != nil {
		return err
	}
	id, err := result.LastInsertId()
	if err != nil {
		return err
	}
	p.ID = id
	return nil
}

What does this code do?

  • FindAll() queries every row and scans them into domain Product structs.
  • FindByID() translates sql.ErrNoRows into the domain's ErrProductNotFound. The HTTP adapter can then decide the right HTTP status code without knowing about SQL.
  • Create() inserts a row and writes the auto-generated ID back onto the Product pointer.
  • Save repository.go.

Seeing red squiggles on the imports?

If your editor shows errors on products-service/internal/domain, make sure your go.mod file is in the products-service root directory and the module name is exactly products-service. The import paths must match the module name plus the directory path.

Help me fix import errors in my Go hexagonal architecture project

✔️ Awesome, I've got everything!

Great. Make sure you have saved repository.go before moving on.

ⓧ I'd like to double check the full code

package sqlite

import (
	"database/sql"

	"products-service/internal/domain"
)

type ProductRepository struct {
	db *sql.DB
}

func NewProductRepository(db *sql.DB) (*ProductRepository, error) {
	_, err := db.Exec(`
		CREATE TABLE IF NOT EXISTS products (
			id INTEGER PRIMARY KEY AUTOINCREMENT,
			name TEXT NOT NULL,
			price REAL NOT NULL,
			stock INTEGER NOT NULL DEFAULT 0
		)
	`)
	if err != nil {
		return nil, err
	}

	// Seed initial data
	_, err = db.Exec(`
		INSERT OR IGNORE INTO products (id, name, price, stock) VALUES
		(1, 'Widget A', 29.99, 100),
		(2, 'Widget B', 49.99, 50),
		(3, 'Gadget C', 99.99, 25)
	`)
	if err != nil {
		return nil, err
	}

	return &ProductRepository{db: db}, nil
}

func (r *ProductRepository) FindAll() ([]domain.Product, error) {
	rows, err := r.db.Query("SELECT id, name, price, stock FROM products")
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var products []domain.Product
	for rows.Next() {
		var p domain.Product
		if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock); err != nil {
			return nil, err
		}
		products = append(products, p)
	}
	return products, rows.Err()
}

func (r *ProductRepository) FindByID(id int64) (*domain.Product, error) {
	var p domain.Product
	err := r.db.QueryRow("SELECT id, name, price, stock FROM products WHERE id = ?", id).
		Scan(&p.ID, &p.Name, &p.Price, &p.Stock)
	if err == sql.ErrNoRows {
		return nil, domain.ErrProductNotFound
	}
	if err != nil {
		return nil, err
	}
	return &p, nil
}

func (r *ProductRepository) Create(p *domain.Product) error {
	result, err := r.db.Exec(
		"INSERT INTO products (name, price, stock) VALUES (?, ?, ?)",
		p.Name, p.Price, p.Stock,
	)
	if err != nil {
		return err
	}
	id, err := result.LastInsertId()
	if err != nil {
		return err
	}
	p.ID = id
	return nil
}

The last adapter is the inbound HTTP handler. It receives HTTP requests, calls the service through the ProductServicePort interface, and returns JSON responses.

  • Create the HTTP handler adapter by adding this code to internal/adapter/httphandler/handler.go:
package httphandler

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"

	"products-service/internal/domain"
	"products-service/internal/port"
)

// ProductHandler is the inbound HTTP adapter
type ProductHandler struct {
	svc port.ProductServicePort
}

// NewProductHandler creates a handler with an injected service port
func NewProductHandler(svc port.ProductServicePort) *ProductHandler {
	return &ProductHandler{svc: svc}
}

// response is the standard JSON envelope
type response struct {
	Data   any    `json:"data,omitempty"`
	Error  string `json:"error,omitempty"`
	Source string `json:"source"`
}

// writeJSON serializes and sends a JSON response
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

What does this code do?

  • ProductHandler depends on port.ProductServicePort (the interface), not the concrete service struct. This is the inbound side of hexagonal architecture.
  • Every response includes a "source": "go-products-service" field. When Traefik routes traffic in the next step, this field proves which service handled the request.
  • Add the three HTTP handler methods below the writeJSON function in handler.go:
// ListProducts handles GET /products
func (h *ProductHandler) ListProducts(w http.ResponseWriter, r *http.Request) {
	products, err := h.svc.ListProducts()
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, response{Error: err.Error(), Source: "go-products-service"})
		return
	}
	writeJSON(w, http.StatusOK, response{Data: products, Source: "go-products-service"})
}

// GetProduct handles GET /products/{id}
func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, response{Error: "invalid product ID", Source: "go-products-service"})
		return
	}
	product, err := h.svc.GetProduct(id)
	if err != nil {
		if errors.Is(err, domain.ErrProductNotFound) {
			writeJSON(w, http.StatusNotFound, response{Error: "product not found", Source: "go-products-service"})
			return
		}
		writeJSON(w, http.StatusInternalServerError, response{Error: err.Error(), Source: "go-products-service"})
		return
	}
	writeJSON(w, http.StatusOK, response{Data: product, Source: "go-products-service"})
}

// CreateProduct handles POST /products
func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Name  string  `json:"name"`
		Price float64 `json:"price"`
		Stock int     `json:"stock"`
	}
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		writeJSON(w, http.StatusBadRequest, response{Error: "invalid JSON body", Source: "go-products-service"})
		return
	}
	product, err := h.svc.CreateProduct(input.Name, input.Price, input.Stock)
	if err != nil {
		if errors.Is(err, domain.ErrInvalidProduct) {
			writeJSON(w, http.StatusBadRequest, response{Error: err.Error(), Source: "go-products-service"})
			return
		}
		writeJSON(w, http.StatusInternalServerError, response{Error: err.Error(), Source: "go-products-service"})
		return
	}
	writeJSON(w, http.StatusCreated, response{Data: product, Source: "go-products-service"})
}

What does this code do?

  • GetProduct() uses r.PathValue("id") to extract path parameters. This is a Go 1.22+ feature that eliminates the need for a third-party router.
  • errors.Is(err, domain.ErrProductNotFound) checks sentinel errors from the domain. The adapter translates domain errors into HTTP status codes (404 for not found, 400 for invalid input).
  • CreateProduct() decodes the request body, delegates to the service port, and returns 201 on success.
  • Save handler.go.

Seeing "undefined: port.ProductServicePort"?

Make sure ports.go is saved at the exact path internal/port/ports.go. The package name in that file must be port (singular), not ports.

Help me fix undefined type errors in my Go handler

✔️ Awesome, I've got everything!

Great. Make sure you have saved handler.go before moving on.

ⓧ I'd like to double check the full code

package httphandler

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"

	"products-service/internal/domain"
	"products-service/internal/port"
)

type ProductHandler struct {
	svc port.ProductServicePort
}

func NewProductHandler(svc port.ProductServicePort) *ProductHandler {
	return &ProductHandler{svc: svc}
}

type response struct {
	Data   any    `json:"data,omitempty"`
	Error  string `json:"error,omitempty"`
	Source string `json:"source"`
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

func (h *ProductHandler) ListProducts(w http.ResponseWriter, r *http.Request) {
	products, err := h.svc.ListProducts()
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, response{Error: err.Error(), Source: "go-products-service"})
		return
	}
	writeJSON(w, http.StatusOK, response{Data: products, Source: "go-products-service"})
}

func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
	idStr := r.PathValue("id")
	id, err := strconv.ParseInt(idStr, 10, 64)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, response{Error: "invalid product ID", Source: "go-products-service"})
		return
	}
	product, err := h.svc.GetProduct(id)
	if err != nil {
		if errors.Is(err, domain.ErrProductNotFound) {
			writeJSON(w, http.StatusNotFound, response{Error: "product not found", Source: "go-products-service"})
			return
		}
		writeJSON(w, http.StatusInternalServerError, response{Error: err.Error(), Source: "go-products-service"})
		return
	}
	writeJSON(w, http.StatusOK, response{Data: product, Source: "go-products-service"})
}

func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
	var input struct {
		Name  string  `json:"name"`
		Price float64 `json:"price"`
		Stock int     `json:"stock"`
	}
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		writeJSON(w, http.StatusBadRequest, response{Error: "invalid JSON body", Source: "go-products-service"})
		return
	}
	product, err := h.svc.CreateProduct(input.Name, input.Price, input.Stock)
	if err != nil {
		if errors.Is(err, domain.ErrInvalidProduct) {
			writeJSON(w, http.StatusBadRequest, response{Error: err.Error(), Source: "go-products-service"})
			return
		}
		writeJSON(w, http.StatusInternalServerError, response{Error: err.Error(), Source: "go-products-service"})
		return
	}
	writeJSON(w, http.StatusCreated, response{Data: product, Source: "go-products-service"})
}
Wire the composition root and verify the service

The composition root is where you assemble all the pieces. It creates the concrete adapter instances, injects them through the interfaces, and starts the server.

  • Create the composition root by adding this code to cmd/api/main.go:
package main

import (
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"products-service/internal/adapter/httphandler"
	"products-service/internal/adapter/sqlite"
	"products-service/internal/domain"

	_ "modernc.org/sqlite"
)

func main() {
	// Read config from environment with sensible defaults
	dbPath := os.Getenv("DB_PATH")
	if dbPath == "" {
		dbPath = "/data/products.db"
	}
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	// Open the SQLite database
	db, err := sql.Open("sqlite", "file:"+dbPath)
	if err != nil {
		log.Fatalf("failed to open database: %v", err)
	}
	defer db.Close()

	// SQLite best practices: single connection, WAL mode
	db.SetMaxOpenConns(1)
	db.SetConnMaxLifetime(time.Hour)

	if _, err := db.Exec("PRAGMA journal_mode = WAL"); err != nil {
		log.Fatalf("failed to set WAL mode: %v", err)
	}
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
		log.Fatalf("failed to enable foreign keys: %v", err)
	}

	// Build the dependency chain: repo -> service -> handler
	repo, err := sqlite.NewProductRepository(db)
	if err != nil {
		log.Fatalf("failed to create repository: %v", err)
	}

	svc := domain.NewProductService(repo)

	handler := httphandler.NewProductHandler(svc)

	// Register routes using Go 1.22+ method-based patterns
	mux := http.NewServeMux()
	mux.HandleFunc("GET /products", handler.ListProducts)
	mux.HandleFunc("GET /products/{id}", handler.GetProduct)
	mux.HandleFunc("POST /products", handler.CreateProduct)

	addr := fmt.Sprintf(":%s", port)
	log.Printf("Products service listening on %s", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

What does this code do?

  • The blank import _ "modernc.org/sqlite" registers the SQLite driver with Go's database/sql package. After this, sql.Open("sqlite", ...) works.
  • The dependency chain is explicit: repo (outbound adapter) is injected into svc (domain service), which is injected into handler (inbound adapter). All wiring happens here in main().
  • mux.HandleFunc("GET /products/{id}", ...) uses Go 1.22+ method-based routing patterns, keeping the HTTP layer as thin as possible.
  • Save main.go.

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.go before moving on.

ⓧ I'd like to double check the full code

package main

import (
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"products-service/internal/adapter/httphandler"
	"products-service/internal/adapter/sqlite"
	"products-service/internal/domain"

	_ "modernc.org/sqlite"
)

func main() {
	dbPath := os.Getenv("DB_PATH")
	if dbPath == "" {
		dbPath = "/data/products.db"
	}
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}

	db, err := sql.Open("sqlite", "file:"+dbPath)
	if err != nil {
		log.Fatalf("failed to open database: %v", err)
	}
	defer db.Close()

	db.SetMaxOpenConns(1)
	db.SetConnMaxLifetime(time.Hour)

	if _, err := db.Exec("PRAGMA journal_mode = WAL"); err != nil {
		log.Fatalf("failed to set WAL mode: %v", err)
	}
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
		log.Fatalf("failed to enable foreign keys: %v", err)
	}

	repo, err := sqlite.NewProductRepository(db)
	if err != nil {
		log.Fatalf("failed to create repository: %v", err)
	}

	svc := domain.NewProductService(repo)

	handler := httphandler.NewProductHandler(svc)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /products", handler.ListProducts)
	mux.HandleFunc("GET /products/{id}", handler.GetProduct)
	mux.HandleFunc("POST /products", handler.CreateProduct)

	addr := fmt.Sprintf(":%s", port)
	log.Printf("Products service listening on %s", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

Before running the service, you need a Dockerfile for the Go service. This uses a multi-stage build to produce a minimal container image.

  • Create the Dockerfile by adding this code to products-service/Dockerfile:
FROM golang:1.26-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /products-service ./cmd/api

FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=builder /products-service /products-service
RUN mkdir -p /data
EXPOSE 8080
CMD ["/products-service"]

What does this Dockerfile do?

  • The builder stage compiles the Go binary with CGO_ENABLED=0 for a fully static binary. This works because modernc.org/sqlite is pure Go and does not need a C compiler.
  • The final image is based on alpine:3.21 and only contains the compiled binary. No Go toolchain in production.
  • Save the Dockerfile.

Now tidy up the module dependencies and verify that everything compiles. Make sure you are inside the products-service directory.

  • Tidy the module and download any missing dependencies by running:
go mod tidy
  • Start the Go service locally by running:
DB_PATH=./test-products.db go run ./cmd/api

You should see Products service listening on :8080 in the terminal output.

  • Open a second terminal window. Press **Cmd+Space** (macOS) or the **Windows** key (Windows) to open your search bar, type Terminal, and press **Enter**.
  • Test the products endpoint by running:
curl localhost:8080/products

You should see a JSON response containing the three seeded products and "source":"go-products-service" in the output.

  • Test fetching a single product by running:
curl localhost:8080/products/1

You should see Widget A with a price of 29.99 and "source":"go-products-service".

  • Go back to the first terminal and press **Ctrl+C** to stop the Go service.
  • Clean up the test database by running:
rm test-products.db

Seeing a compilation error?

Check that every file has the correct package declaration matching its directory name: domain for files in internal/domain/, port for internal/port/, sqlite for internal/adapter/sqlite/, and httphandler for internal/adapter/httphandler/.

Also verify you ran go mod tidy before go run. This command resolves all transitive dependencies and updates go.sum.

Help me fix my Go service compilation error

Your Go service is working as a standalone replacement for the monolith's Products domain. Next up, you will wire both services behind Traefik to complete the Strangler Fig migration.

Set Up the Strangler Fig with Traefik

Your Go Products service is built, Dockerized, and ready to serve traffic. The PHP monolith is still running on its own, handling both /products and /orders. Now you need a way to run both services side by side and incrementally shift traffic from the old to the new.

This is the Strangler Fig pattern in action. You place a reverse proxy in front of both services and use path-based routing rules to decide which service handles which requests. In this step, you will wire up Traefik as that proxy, route /products to Go and everything else to PHP.

In this step, get ready to:
  • Add Traefik as the single entry point for all HTTP traffic.
  • Route product requests to the Go service and order requests to the PHP monolith.
  • Verify the Strangler Fig cutover with curl and the Traefik dashboard.
Add Traefik as the reverse proxy

Right now the monolith owns port 80 directly. To run two services behind one port, you need a reverse proxy that inspects each incoming request path and forwards it to the right backend.

Why Traefik instead of Nginx?

Traefik has native Docker integration. It reads routing rules directly from container labels in your docker-compose.yml, so the routing config lives right next to the service definition.

With Nginx, you would need a separate config file and manual reloads every time you add a service. Traefik discovers new containers automatically.

  • Open docker-compose.yml in your editor.
  • Replace the entire contents with the following configuration, which adds a Traefik service and updates the monolith with routing labels:
services:
  # Reverse proxy - single entry point for all traffic
  traefik:
    image: traefik:v3.6
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=proxy"
      - "--entrypoints.web.address=:80"
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - proxy

  # Legacy PHP monolith - catch-all route with lowest priority
  monolith:
    build: ./monolith
    networks:
      - proxy
    volumes:
      - monolith-data:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.monolith.rule=PathPrefix(`/`)"
      - "traefik.http.routers.monolith.entrypoints=web"
      - "traefik.http.routers.monolith.priority=1"
      - "traefik.http.services.monolith.loadbalancer.server.port=80"

networks:
  proxy:
    name: proxy

volumes:
  monolith-data:

What does this configuration do?

  • The traefik service mounts the Docker socket (/var/run/docker.sock) so it can discover other containers and read their labels.
  • The --providers.docker.exposedbydefault=false flag means services must explicitly opt in with traefik.enable=true.
  • The monolith's PathPrefix(`/`) rule with priority=1 makes it the catch-all. Any request that no higher-priority route claims ends up here.
  • Port 8080:8080 exposes the Traefik dashboard, which you will use to confirm routing is working.
  • The monolith no longer maps port 80 directly. Traefik now owns that port and forwards traffic internally.

You won't start the stack yet. The next substep adds the Go Products service so you can launch everything together.

Route product traffic to the Go service

Traefik uses a priority number to break ties when multiple routes match the same request path. The monolith's PathPrefix(`/`) technically matches every request, including /products. By giving the Go service a higher priority, you ensure Traefik sends product requests to Go and lets the monolith keep everything else.

  • In docker-compose.yml, find the monolith: service block. Add the following products-service block directly below it (above the networks: section):
  # New Go service - claims /products with higher priority
  products-service:
    build: ./products-service
    networks:
      - proxy
    volumes:
      - products-data:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.products.rule=PathPrefix(`/products`)"
      - "traefik.http.routers.products.entrypoints=web"
      - "traefik.http.routers.products.priority=10"
      - "traefik.http.services.products.loadbalancer.server.port=8080"

How does priority-based routing work?

The Go service declares priority=10 for PathPrefix(`/products`). The monolith declares priority=1 for PathPrefix(`/`). When a request for /products arrives, both rules match. Traefik picks the higher priority (10 beats 1) and sends it to Go.

A request for /orders only matches the monolith's PathPrefix(`/`) rule. The monolith handles it as before. This is the core of the Strangler Fig: old and new coexist, and you migrate one route at a time.

  • Scroll down to the volumes: section at the bottom of the file.
  • Add products-data: below monolith-data: so both volumes are declared:
volumes:
  monolith-data:
  products-data:
  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved docker-compose.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml with all three services, the proxy network, and both volumes.

services:
  traefik:
    image: traefik:v3.6
    command:
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=proxy"
      - "--entrypoints.web.address=:80"
    ports:
      - "80:80"
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
      - proxy

  monolith:
    build: ./monolith
    networks:
      - proxy
    volumes:
      - monolith-data:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.monolith.rule=PathPrefix(`/`)"
      - "traefik.http.routers.monolith.entrypoints=web"
      - "traefik.http.routers.monolith.priority=1"
      - "traefik.http.services.monolith.loadbalancer.server.port=80"

  products-service:
    build: ./products-service
    networks:
      - proxy
    volumes:
      - products-data:/data
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.products.rule=PathPrefix(`/products`)"
      - "traefik.http.routers.products.entrypoints=web"
      - "traefik.http.routers.products.priority=10"
      - "traefik.http.services.products.loadbalancer.server.port=8080"

networks:
  proxy:
    name: proxy

volumes:
  monolith-data:
  products-data:

YAML indentation problems?

YAML is whitespace-sensitive. Each label line under labels: must start with exactly 6 spaces and a dash. If docker compose up throws a parse error, compare your file against the full code in the tab above.

Help me fix a YAML indentation error in my docker-compose.yml

Verify the Strangler Fig cutover

Everything is configured. Time to start the full stack and prove that the old monolith and new Go service coexist behind a single proxy.

  • Build and start all three services by running this command from your project root (the strangler-fig-migration folder):
docker compose up --build -d

What does this command do?

  • The --build flag rebuilds the Docker images for both the monolith and the Go service.
  • The -d flag runs the containers in detached mode so your terminal stays free.
  • Wait about 30 seconds for all three containers to start.
  • Confirm all containers are running:
docker compose ps

You should see three containers listed: traefik, monolith, and products-service, all with a status of Up.

A container exited or won't start?

  • Check the logs for the failing container. For example, run docker compose logs monolith or docker compose logs products-service to see the error output.
  • If port 80 is already in use, make sure no other web server (like a previous monolith container) is running. Stop old containers with docker compose down first.
  • If the Traefik container exits, check that Docker Desktop is running and that /var/run/docker.sock is mounted correctly in your YAML.

Help me debug why my Docker Compose containers won't start

  • Seed the monolith database with sample data by running:
curl -s -X POST localhost/seed

You should see {"message":"Database seeded"} in the response. This populates the monolith's Orders data for testing.

  • Test the product endpoint to confirm it hits the Go service:
curl -s localhost/products | python3 -m json.tool

Look for "source": "go-products-service" in the JSON response. This confirms Traefik routed the request to the Go service, not the PHP monolith.

  • Test the orders endpoint to confirm it still hits the PHP monolith:
curl -s localhost/orders | python3 -m json.tool

This response should contain "source": "php-monolith". The monolith still owns /orders because no higher-priority route claimed that path.

  • Open the Traefik dashboard in your browser by navigating to:
http://localhost:8080

You should see two HTTP routers listed: products with priority 10 and monolith with priority 1. Both should show a green status.

Products endpoint returning source php-monolith?

  • Check that your products-service labels in docker-compose.yml have priority=10, not priority=1. Both services at priority 1 means Traefik picks arbitrarily.
  • Make sure the products-service container is healthy by running docker compose logs products-service. If it crashed, Traefik falls back to the monolith.
  • Verify the traefik.enable=true label exists on the products-service. Without it, Traefik ignores the container entirely.

Help me debug why Traefik is routing /products to the wrong service

You now have a working Strangler Fig migration. The Go service owns /products while the PHP monolith keeps /orders. To migrate another domain, you would build a new service, add it to Docker Compose with Traefik labels, and the monolith shrinks a little more. Next up, the Secret Mission challenges you to add health-check fallback routing so that Traefik automatically falls back to the monolith if the Go service goes down.

Secret mission

Add Health-Check Fallback Routing

Your Strangler Fig setup routes traffic between the PHP monolith and the Go service. But what happens if the Go service crashes? Right now, requests to /products would return a 502 error. In this secret mission, you'll add a health-check endpoint and configure Docker so that Traefik automatically falls back to the PHP monolith when the Go service is unhealthy.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker, so there are no ongoing cloud costs.

Resources you used:

  • Docker containers: Traefik reverse proxy, PHP monolith, Go products service.
  • Docker volumes: monolith-data and products-data (SQLite databases).
  • Docker network: proxy.
  • Local project files: monolith/, products-service/, docker-compose.yml, and analysis documents.

Keep everything running

No action needed. Choose this if you are still actively experimenting with the Strangler Fig setup or want to keep testing routes.

  • Your Docker Compose stack continues running in the background.
  • The containers use minimal resources while idle.
  • Access the Traefik dashboard anytime at localhost:8080 and test routes at localhost/products or localhost/orders.

Pause - I'll come back to this later

Shut down the running containers to free up memory and CPU. Your project files and Docker volumes stay on disk so you can restart later.

  • Stop the Docker Compose stack by running this command from your project folder:
docker compose down
  • This stops and removes the containers but keeps your volumes and images intact.
  • To restart everything later, navigate to your project folder and run:
docker compose up --build -d

Delete - I don't want to use this again

Remove all containers, volumes, networks, and images created by this project. This frees up disk space completely.

  • Stop containers and remove volumes and networks by running:
docker compose down -v --remove-orphans
  • Remove the Docker images built for this project by running:
docker image prune -f
  • Delete the project folder from your Desktop by running:
cd ~/Desktop
rm -rf strangler-fig-migration
  • Your machine is now completely clean of this project.

Mission accomplished

You Did It!

Nice work! You just executed a complete monolith modernization pipeline, from tangled legacy code to a clean, independently deployable microservice.

You've learned how to:

  • Analyzed a PHP monolith using dePHPend dependency analysis and a data-consistency-centered knowledge graph to identify safe extraction points based on data ownership.
  • Built a Go microservice using hexagonal architecture with clean domain, port, and adapter layers, backed by a SQLite persistence adapter.
  • Deployed the Strangler Fig pattern with Traefik routing traffic between the legacy PHP monolith and the new Go service simultaneously.
  • Secret Mission: Added health-check-based fallback routing so Traefik automatically shifts traffic back to the PHP monolith if the Go service goes down.

Ready to quiz yourself?