Shape: Microservices

A microservices project has three or more independently deployable services. This is the most complex project shape, and the configuration depends heavily on whether your services live in one repository (monorepo) or separate repositories (polyrepo). This tutorial covers both variants.

When this shape applies

Use the microservices shape when your system has:

  • Three or more services that are developed and deployed independently.
  • Services that communicate over HTTP, gRPC, message queues, or event streams.
  • Services potentially written in different languages.
  • Independent scaling requirements per service.

If you have exactly two deployable units (frontend + backend), see Shape: Full-Stack. If everything deploys as one unit, see Shape: Single Repo.

Headless service (backend-only)

A common shape is a pure backend service with no UI — an internal API, a worker, a CLI binary, or a library exposed over RPC. The init questionnaire supports this case directly, even though the platforms[] enum can look frontend-only at first glance.

Configure two fields explicitly when the service ships no UI:

# .gaia/config/project-config.yaml
compliance:
  ui_present: false       # disables UX, a11y, and design-token gates

platforms:
  - server                # canonical headless platform identifier

With ui_present: false, the framework correctly skips /gaia-create-ux, /gaia-validate-design-a11y, and /gaia-review-a11y with a neutral message instead of generating hollow UX artifacts. With platforms: [server], the /gaia-config-device-target gate is satisfied without a mobile / web matrix.

See /gaia-config-compliance and /gaia-config-platform for the post-init editors.

Monorepo vs polyrepo

The first decision is how to organize your repositories:

Monorepo Polyrepo
All services in... One repository Separate repositories
Shared code Easy -- import from a shared package Harder -- publish as a library, version independently
Atomic changes One PR can change multiple services Coordinated PRs across repos
CI complexity Higher -- path filters required Lower -- each repo has its own CI
Team autonomy Lower -- shared branch protection rules Higher -- each team owns their repo
GAIA config One project-config.yaml with multiple stacks One project-config.yaml per repo, each with one stack

Choosing between them

Start with a monorepo if your services are tightly coupled, share types or schemas, or are maintained by the same team. Switch to polyrepo when team autonomy becomes more important than code-sharing convenience -- typically when you have 3+ teams working on different services.

Monorepo configuration

In a monorepo, all services live under a shared directory structure and share a single project-config.yaml. Each service is a separate stack with its own path.

Folder layout

my-platform/
  .gaia/
    config/
      project-config.yaml
  docs/
    planning-artifacts/
    implementation-artifacts/
  services/
    user-service/
      src/
      tests/
      Dockerfile
    order-service/
      src/
      tests/
      Dockerfile
    notification-service/
      src/
      tests/
      Dockerfile
  packages/
    shared-types/      # Shared code, not a deployable service
      src/
      tests/
  infrastructure/
    terraform/
    k8s/
  README.md

Stacks configuration

# .gaia/config/project-config.yaml
stacks:
  - name: user-service
    language: go
    path: services/user-service
    test_command: go test ./...
    build_command: docker build -t user-service .
    lint_command: golangci-lint run
  - name: order-service
    language: typescript
    framework: nestjs
    path: services/order-service
    test_command: npm test
    build_command: docker build -t order-service .
    lint_command: npm run lint
  - name: notification-service
    language: python
    framework: fastapi
    path: services/notification-service
    test_command: pytest
    build_command: docker build -t notification-service .
    lint_command: ruff check src/

Each stack has its own language, framework, and commands. GAIA uses the path field to determine which stack a story belongs to and which tests to run.

Polyrepo configuration

In a polyrepo, each service has its own repository and its own project-config.yaml. Each config has a single stack.

Per-repo configuration

# user-service/.gaia/config/project-config.yaml
project:
  name: user-service
  description: User authentication and profile management

stacks:
  - name: api
    language: go
    path: .
    test_command: go test ./...
    build_command: docker build -t user-service .
    lint_command: golangci-lint run

platforms:
  - web

ci_cd:
  platform: github-actions
  preset: standard
  promotion_chain:
    - dev
    - staging
    - production

This looks identical to a single-repo configuration -- because each repository IS a single-repo project from GAIA's perspective. The microservices complexity lives in the interactions between services, not in the per-service configuration.

Coordinating across repos

The challenge with polyrepo is coordination. When a change in the user-service requires a corresponding change in the order-service, you need to:

  1. Deploy the backward-compatible version of user-service first.
  2. Deploy the order-service change that depends on the new user-service API.
  3. Optionally, deploy a cleanup version of user-service that removes the old API.

This is the "expand and contract" pattern. GAIA supports it through coordinated deployment plans generated by /gaia-release-plan.

Contract testing

In a microservices architecture, the most common source of production failures is a service changing its API in a way that breaks a consumer. Contract tests catch this before deployment.

What contract testing checks

  • The provider (the service exposing the API) still returns the fields that consumers expect.
  • The consumer (the service calling the API) still sends requests that the provider can handle.
  • Data formats, field names, and types have not changed in breaking ways.

Where contract tests live

In a monorepo, contract tests live alongside integration tests. Each service's test suite includes tests that verify its contracts with other services.

In a polyrepo, contract tests typically use a broker (like Pact Broker) where providers publish their contracts and consumers verify against them.

Configuring contract tests

# .gaia/config/project-config.yaml (monorepo)
test_execution:
  tiers:
    pr:
      command: npm test -- --exclude=contract
      timeout_seconds: 120
    contract:
      command: npm run test:contract
      timeout_seconds: 180
    nightly:
      command: npm test
      timeout_seconds: 600

Contract tests are slower than unit tests but faster than E2E tests. Run them on PR (if fast enough) or in a separate CI job that runs after both provider and consumer PRs are merged.

Coordinated deploys

When multiple services need to be deployed together (e.g., a new feature that spans user-service and order-service), coordinate the deployment to avoid downtime:

  1. Feature flags: Deploy all services with the new code behind a feature flag. Enable the flag once all services are deployed. This is the safest approach.
  2. Backward-compatible APIs: Deploy the provider first with a new API version that supports both old and new formats. Deploy the consumer next. Clean up the old API version later.
  3. Blue-green deployment: Deploy all services to a new environment (green), switch traffic from old (blue) to green once all health checks pass.

Use /gaia-release-plan to generate a deployment plan that accounts for service dependencies and ordering.

Shared platforms and infrastructure

Microservices often share infrastructure: a message broker, a service mesh, a shared database cluster, or a Kubernetes namespace. Configure shared platforms in the platforms section:

# .gaia/config/project-config.yaml (monorepo)
platforms:
  - web
  - api

# Infrastructure shared across all services
# (not GAIA config -- just for reference in your infra code)
# - PostgreSQL cluster (shared)
# - Redis (shared cache)
# - RabbitMQ (message broker)
# - Kubernetes namespace per environment

GAIA's platform configuration tells the framework which types of tests and reviews to include. Listing web enables accessibility and responsive testing. Listing api enables API contract validation and performance testing.

For infrastructure design, use /gaia-infra-design to generate a topology document covering deployment targets, environment design, and observability.

CI for microservices

Monorepo CI

In a monorepo, enable path filters so each service's tests run only when that service's code changes:

# .gaia/config/project-config.yaml
ci_cd:
  platform: github-actions
  preset: standard
  promotion_chain:
    - dev
    - staging
    - production
  path_filters:
    enabled: true
  concurrency:
    group: ci-${{ github.ref }}
    cancel_in_progress: true

Running /gaia-ci-setup generates one workflow per stack, each triggered by changes to that stack's path. A change to services/user-service/ triggers only the user-service CI job.

Polyrepo CI

In a polyrepo, each repository has its own CI workflow. No path filters needed -- the entire repository is one stack. Run /gaia-ci-setup in each repository independently.

Cross-service integration tests

For both variants, cross-service integration tests (tests that verify interactions between services) should run in a dedicated CI job that is triggered after individual service CIs pass. This is typically a nightly job or a post-merge job on the main branch.

# .gaia/config/project-config.yaml
test_execution:
  tiers:
    pr:
      command: npm test -- --exclude=integration
      timeout_seconds: 120
    integration:
      command: docker-compose up -d && npm run test:integration
      timeout_seconds: 300
    nightly:
      command: npm run test:all
      timeout_seconds: 900

Complete monorepo example

# .gaia/config/project-config.yaml
project:
  name: ecommerce-platform
  description: E-commerce platform with user, order, and notification services

stacks:
  - name: user-service
    language: go
    path: services/user-service
    test_command: go test ./...
    build_command: docker build -t user-service services/user-service
    lint_command: cd services/user-service && golangci-lint run
  - name: order-service
    language: typescript
    framework: nestjs
    path: services/order-service
    test_command: cd services/order-service && npm test
    build_command: docker build -t order-service services/order-service
    lint_command: cd services/order-service && npm run lint
  - name: notification-service
    language: python
    framework: fastapi
    path: services/notification-service
    test_command: cd services/notification-service && pytest
    build_command: docker build -t notification-service services/notification-service
    lint_command: cd services/notification-service && ruff check src/
  - name: web-frontend
    language: typescript
    framework: react
    path: packages/web
    test_command: cd packages/web && npm test
    build_command: cd packages/web && npm run build
    lint_command: cd packages/web && npm run lint

platforms:
  - web
  - api

ci_cd:
  platform: github-actions
  preset: standard
  promotion_chain:
    - dev
    - staging
    - production
  path_filters:
    enabled: true
  concurrency:
    group: ci-${{ github.ref }}
    cancel_in_progress: true
  triggers:
    pull_request:
      checks:
        - lint
        - unit-tests
    push_to_dev:
      checks:
        - smoke-test
        - deploy-dev
    push_to_staging:
      checks:
        - smoke-test
        - integration-tests
        - deploy-staging
    push_to_production:
      checks:
        - smoke-test
        - deploy-production

environments:
  dev:
    url: https://dev.ecommerce.example.com
    auto_deploy: true
  staging:
    url: https://staging.ecommerce.example.com
    auto_deploy: true
  production:
    url: https://ecommerce.example.com
    auto_deploy: false
    requires_approval: true

test_execution:
  timeout_seconds: 300
  coverage:
    enabled: true
    threshold: 75
  tiers:
    pr:
      timeout_seconds: 120
    nightly:
      timeout_seconds: 900
  smoke:
    command: npm run test:smoke
    timeout_seconds: 60

Complete polyrepo example

In a polyrepo setup, each service repository has a simple config:

# order-service/.gaia/config/project-config.yaml
project:
  name: order-service
  description: Order processing and fulfillment

stacks:
  - name: api
    language: typescript
    framework: nestjs
    path: .
    test_command: npm test
    build_command: docker build -t order-service .
    lint_command: npm run lint

platforms:
  - api

ci_cd:
  platform: github-actions
  preset: standard
  promotion_chain:
    - dev
    - staging
    - production
  concurrency:
    group: ci-${{ github.ref }}
    cancel_in_progress: true

environments:
  dev:
    url: https://order-service.dev.example.com
    auto_deploy: true
  staging:
    url: https://order-service.staging.example.com
    auto_deploy: true
  production:
    url: https://order-service.example.com
    auto_deploy: false
    requires_approval: true

test_execution:
  default_command: npm test
  timeout_seconds: 300
  coverage:
    enabled: true
    threshold: 80

Each service is configured independently. The microservices complexity -- contract testing, coordinated deploys, shared infrastructure -- is handled through your deployment process and tooling, not through GAIA configuration.

When to split or merge repositories

Signs you should split a monorepo into polyrepos:

  • Different teams own different services and want independent release cycles.
  • CI takes too long because all services' tests run on every change (and path filters are not sufficient).
  • Access control is needed -- some teams should not be able to modify other teams' services.

Signs you should merge polyrepos into a monorepo:

  • Cross-service changes are frequent and coordinating PRs across repos is painful.
  • Shared code duplication is growing.
  • You spend more time on release coordination than on feature development.

Changing repo structure

Switching between monorepo and polyrepo is a significant undertaking. Make the choice early based on your team structure and expected growth. If in doubt, start with a monorepo -- it is easier to split later than to merge.

What to read next