How to Understand a Microservices Architecture You Inherited

Microservices move complexity from code into the spaces between services. Here is how to map those spaces: inventory, contracts, messaging, data ownership and traces.

By The OpenVisio Team · · 11 min read

In a monolith, the hard part of understanding the system is the code. In a microservices architecture, each service is often small and readable, and the hard part is everything between them: which service calls which, what contracts they agree on, which events flow through which topics, who owns which data, and what happens when one of them is slow. None of that lives in a single repository, and much of it is not written down anywhere except in configuration and in the traffic itself.

This guide is a method for building a working model of a microservices system you inherited. It starts with an inventory, moves to synchronous contracts and asynchronous messaging, then data ownership, and finishes with distributed tracing, which is the closest thing to ground truth a distributed system has. The goal is a service map you trust, with the main request and event flows drawn on it.

Key takeaways

  • Start with an inventory: every deployable service, its owner, its repo, and how it is deployed. Deployment config is usually more complete than any wiki.
  • Service-to-service calls are found in configuration (URLs, service names, environment variables) and in client code. Collect both.
  • Contracts live in OpenAPI specs, protobuf files and event schemas. Treat them as the system's real interfaces.
  • Asynchronous messaging hides coupling. For every topic or queue, record producers, consumers, schema and delivery guarantees.
  • Know which service owns each piece of data. Shared databases are the most important thing to find.
  • Distributed traces show what actually happens. Use them to confirm or correct the map you built from code and config.

Step 1: Build an inventory

Before reading any service's code, list the services. Several sources usually overlap and each one is incomplete:

  • A service catalog, if the organization has one. Backstage, for example, reads catalog-info.yaml files from each repo with the component name, owner, lifecycle and the APIs it provides and consumes.
  • Deployment configuration: Kubernetes manifests, Helm charts, Kustomize overlays, Terraform, docker-compose.yml for local development, or the platform's own config (ECS task definitions, Cloud Run services).
  • The running cluster:
kubectl get deployments,statefulsets,cronjobs -A -o wide
kubectl get services -A
kubectl get ingress -A
  • CI pipelines, which show which repos build which images.
  • Monitoring, where every service that emits metrics appears whether or not anyone documented it.

Build a table with one row per service: name, owning team, repository, language and framework, how it is deployed, its public entry points (ingress hosts, gateway routes) and its data stores. Cronjobs and workers count as services; they are easy to forget and often do important work.

A local docker-compose.yml deserves special attention. It is frequently the only file that lists every service together with its dependencies, ports and environment variables in one place.

Step 2: Find the synchronous calls

Services call each other over HTTP, gRPC or GraphQL. You can find those calls from two directions.

Configuration. Services usually discover each other through environment variables or config keys holding URLs or service names. Search deployment config and application config for them:

grep -rhoE "[A-Z_]+_(URL|HOST|ENDPOINT|ADDR)=?[^ ]*" k8s/ helm/ docker-compose*.yml | sort -u
grep -rn "http://[a-z-]*\.\(svc\|internal\)\|:50051" --include=*.yaml --include=*.yml --include=*.env .

In Kubernetes, a call to http://payments.billing.svc.cluster.local or simply http://payments tells you the caller depends on the payments service.

Client code. In each service, find where outbound clients are constructed: generated gRPC stubs, OpenAPI-generated clients, a shared HTTP client wrapper, or ad hoc fetch and requests calls. Each client class corresponds to an edge on the service map.

Draw the edges. For each one, note the protocol, whether the call is on a user-facing request path, and what the caller does if it fails (timeout, retry, fallback, or propagate the error). Long synchronous chains, where a user request waits on four services in sequence, are where latency and cascading failures come from.

If there is an API gateway or service mesh, its configuration is another source: gateway route tables map public paths to services, and mesh resources like Istio VirtualService objects describe internal routing, retries and timeouts.

Step 3: Read the contracts

The interfaces between services matter more than the code inside them, because they are the hardest things to change. Collect them:

Contract typeWhere to find itWhat to read
RESTopenapi.yaml, swagger.json, or generated at /v3/api-docs and similarPaths, request and response schemas, error formats, versioning
gRPC.proto files, often in a shared proto/ or api/ repoServices, RPC methods, message types, field numbers
GraphQLschema.graphql, federation subgraph schemasTypes, queries, mutations, ownership of each type
EventsAsyncAPI documents, Avro or Protobuf schemas in a schema registry, JSON Schema filesEvent names, payload fields, keys, versioning rules

For gRPC services with server reflection enabled, you can explore a running service directly:

grpcurl -plaintext localhost:50051 list
grpcurl -plaintext localhost:50051 describe acme.payments.v1.PaymentService

When reading contracts, look for how change is managed. Versioned paths (/v1/, /v2/), versioned protobuf packages (acme.payments.v1), reserved field numbers, and schema registry compatibility settings (backward, forward, full) tell you how careful the system is about breaking consumers. Contract tests, such as Pact files, tell you which consumers depend on which parts of a provider's API.

Step 4: Map the asynchronous messaging

Messaging decouples services in time but not in meaning. A consumer that depends on an event's fields is as coupled to the producer as an HTTP caller, but the coupling is invisible from the producer's code. So messaging needs its own map.

For each broker (Kafka, RabbitMQ, SQS and SNS, Google Pub/Sub, NATS), list the topics or queues, then for each one record producers, consumers, the message schema, the key, and the delivery semantics.

For Kafka:

kafka-topics.sh --bootstrap-server localhost:9092 --list
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --list
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group invoice-service

The consumer group description shows which topics a group reads and its lag per partition. In code, search for producer and consumer setup: topic name constants, @KafkaListener annotations in Spring, consumer.subscribe([...]) calls, or framework configuration files.

Then check how each consumer handles the realities of messaging:

  • At-least-once delivery. Most brokers can deliver a message more than once. Consumers should be idempotent, usually by recording processed message IDs or making writes naturally idempotent.
  • Ordering. Kafka guarantees order only within a partition, so the message key decides what is ordered relative to what.
  • Failure. Find the retry policy and the dead-letter queue or topic. Messages that land there are silently not processed unless someone watches it.
  • Publishing reliably. A service that writes to its database and then publishes an event can do one without the other if it crashes in between. The transactional outbox pattern (write the event to an outbox table in the same transaction, then relay it) is the common fix. If you find an outbox table, read the relay.

Draw these flows on the service map with a different line style from synchronous calls. A sequence like "order placed, payment captured, inventory reserved, shipment created" that spans four services and three topics is often the core business process of the whole system, and it may not be documented anywhere.

Step 5: Find who owns which data

Each service should own its data and expose it only through its API or events. In practice, inherited systems often have exceptions. For each data store, record which services connect to it:

grep -rhoE "(DATABASE_URL|DB_HOST|SPRING_DATASOURCE_URL|MONGO_URI|REDIS_URL)[^ ]*" k8s/ helm/ | sort | uniq -c

Two services pointing at the same database is the most important architectural fact you can discover, because it means a schema change in one can break the other with no contract in between. Also look for read replicas used for reporting, caches shared between services, and services that keep local copies of another service's data built from events. Those copies are a legitimate pattern, but you need to know how they are kept up to date and how stale they can be.

Step 6: Confirm with distributed tracing

Everything so far is inferred from code and configuration. Distributed tracing shows what actually happened for real requests. If the system is instrumented with OpenTelemetry (or older Zipkin or Jaeger clients), each request carries a trace context, typically in the W3C traceparent header, and every service adds spans to the same trace.

Open the tracing backend (Jaeger, Grafana Tempo, Zipkin, or a commercial APM) and do three things:

  1. Look at the service graph that most backends derive from trace data. Compare it with the map you drew. Edges you missed and edges that no longer exist both show up immediately.
  2. Read a few traces for the main user flows. A checkout trace shows the full chain of calls, their order, which ones run in parallel, and where the time goes.
  3. Check whether traces cross message boundaries. Context propagation through Kafka headers or message attributes is often missing, which means asynchronous flows appear as separate traces. That is useful to know before an incident.

If tracing is absent, add it to the top of your list of improvements. Without it, diagnosing cross-service problems relies on correlating logs by timestamp, which is slow under pressure. Debugging an unfamiliar service during an incident covers how to orient when you have to do that anyway.

Step 7: Read one service in depth

Once the map exists, read one central service properly, using the framework-specific approach: Spring Boot, Go, or whatever the service is built with. Pick the one with the most edges on your map. You will learn the conventions most services share, such as how they handle configuration, auth between services, retries and logging, and you will understand the busiest node in the graph.

A first-week checklist

  1. Build the inventory table from catalog, deployment config, cluster and CI.
  2. Read docker-compose.yml for local development if one exists.
  3. Extract service URLs from config and outbound clients from code; draw the synchronous edges.
  4. Collect OpenAPI, protobuf, GraphQL and event schemas.
  5. List topics and queues with producers, consumers, keys and DLQs.
  6. Map data stores to services and flag any shared database.
  7. Compare your map with the tracing backend's service graph.
  8. Read three traces for the most important user flows.
  9. Read the most connected service in depth.

The map you build is a dependency graph at the service level, and it goes stale for the same reasons. Within each service repository, Codebase Knowledge by OpenVisio can index the code in your browser and show its module structure in Explore, which helps when you move from the system map down into a single service's internals.

microservicesdistributed tracingevent-driven architecturesystem design

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading