Mapping Microservice Dependencies for Local Dev
Map microservice dependencies for local dev so services resolve each other on boot. Fix connection-refused and DNS failures with deterministic dependency graphs.
A freshly cloned stack boots, then the frontend logs ECONNREFUSED against the backend and the backend cannot resolve db — because nothing declared which service depends on which. This walkthrough builds an explicit dependency map so local services start in the right order and resolve each other by name. It is the hands-on companion to dependency tree visualization within onboarding architecture and friction mapping.
Diagnostic
Run the triage triad to surface unhealthy containers, closed ports, and failing health endpoints in one pass:
#!/usr/bin/env bash
set -euo pipefail
docker compose ps -a --format '{{.Name}} {{.State}}'
nc -zv localhost 5432 6379 8080 || true
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/health || true
# Ask Docker's embedded resolver directly
docker compose exec api dig +short @127.0.0.11 db || true
Expected BAD output — the resolver returns nothing and the port probe is refused:
api running
backend exited
nc: connect to localhost port 8080 (tcp) failed: Connection refused
000
;; ANSWER SECTION returned 0 records (SERVFAIL)
A SERVFAIL or empty answer confirms the service is not on a shared network, or its dependency never started.
Root Cause
The codebase hardcodes localhost/127.0.0.1 endpoints and the Compose file omits depends_on edges, so there is no service registry and no startup ordering. Inside a container, localhost is the container itself — not its sibling service — so connections are refused, and services on different default networks cannot resolve each other's names at all. Two failure shapes follow from this. The first is a timing race: the backend starts and connects to db before Postgres has finished initializing, so it crashes once and never retries. The second is a topology gap: a service was never placed on the shared bridge network, so Docker's embedded resolver at 127.0.0.11 has no record of it and returns SERVFAIL. Both look identical from the application logs — "cannot reach dependency" — which is why an explicit, declared graph is worth more than any amount of retry logic. Tracing that graph is exactly what detecting circular dependencies in local builds extends when the missing edge is a cycle rather than an omission.
Resolution
Replace static endpoints with environment-driven injection and declare the graph explicitly.
- Audit the codebase for hardcoded endpoints:
#!/usr/bin/env bash set -euo pipefail grep -rn '127\.0\.0\.1\|localhost' src/ \ --include='*.go' --include='*.py' --include='*.ts' || echo "no hardcoded endpoints" - Drive routing from
.env.local:# .env.local DB_HOST=postgres CACHE_HOST=redis AUTH_HOST=auth-service - Declare dependencies and a shared network in Compose:
# docker-compose.yml services: frontend: build: ./frontend environment: BACKEND_HOST: backend depends_on: backend: condition: service_healthy networks: [app-net] backend: build: ./backend environment: DB_HOST: "${DB_HOST}" CACHE_HOST: "${CACHE_HOST}" depends_on: postgres: condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 10s timeout: 5s retries: 5 networks: [app-net] postgres: image: postgres:16-alpine environment: POSTGRES_PASSWORD: localdev healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s timeout: 3s retries: 5 networks: [app-net] networks: app-net: driver: bridge - Validate interpolation, then boot blocking on health:
#!/usr/bin/env bash set -euo pipefail docker compose --env-file .env.local config --quiet docker compose --env-file .env.local up -d --wait
Expected Output
With the graph declared, names resolve and inter-service calls succeed:
postgres healthy
backend healthy
frontend running
$ docker compose exec frontend curl -s -o /dev/null -w '%{http_code}\n' http://backend:8080/ping
200
Prevention
- Add a pre-commit hook that runs
docker compose config --quietand rejects unresolved variables or malformed YAML. - Lint for hardcoded URLs in CI; require all endpoints to come from
SERVICE_HOST/SERVICE_PORTstyle variables. - Keep the declared graph in sync with the rendered artifact from dependency tree visualization so reviewers can see new edges.
macOS (Docker Desktop): containers reach the host via
host.docker.internal, but sibling services must use their Compose service name, neverlocalhost. WSL2: keep the repo on the Linux filesystem so file-watch healthchecks fire reliably. Apple Silicon (ARM64): if an upstream image lacks an arm64 manifest, pinplatform: linux/amd64so the service starts rather than failing the healthcheck.
Rollback
#!/usr/bin/env bash
set -euo pipefail
docker compose -f docker-compose.yml down --remove-orphans --volumes
docker network prune -f
git checkout -- docker-compose.yml