ADR-011: Separate App API and Public API Surfaces

ADR-011: Separate App API and Public API Surfaces

Status: Accepted Date: 2026-03-11 Context: API strategy for first-party clients vs external/org integrations

Decision

We maintain two separate API surfaces that share the same application layer (commands/queries/handlers):

SurfaceURL prefixAuthAudience
App API/app/api/v1/OIDC Bearer (Zitadel)Mobile app, webapp
Public API/api/v1/API key (Bearer)Org integrations, third-party consumers

Firewall separation

firewalls:
    app_api:
        pattern: ^/app/api/
        stateless: true
        provider: zitadel_provider
        custom_authenticators:
            - ZitadelBearerAuthenticator
    public_api:
        pattern: ^/api/
        stateless: true
        provider: api_key_provider
        custom_authenticators:
            - ApiKeyAuthenticator

Controller structure

Presentation/Controller/
  AppApi/           ← /app/api/v1/ (first-party, OIDC user context)
  Api/              ← /api/v1/ (public, API key + scopes)

Rationale

  1. Breaking changes are isolated. The app API can evolve freely — we control both client and server. The public API follows strict semver with N-1 support (12+ months).

  2. Permissions model is clean. App users have roles (ROLE_USER, ROLE_TENANT_ADMIN) with full user context. API keys have explicit scopes (groups:read, events:write). No if isApiKey branching in controllers.

  3. Public API is a strict subset. Not every app endpoint needs external exposure. Admin operations (move org, delete, etc.) stay app-only unless deliberately published.

  4. Response shapes can differ. App API returns rich, nested data optimized for UI rendering. Public API returns flat, stable structures with explicit field allowlists.

  5. Rate limiting and quotas differ. First-party clients get generous limits. Public API gets per-key rate limiting and usage tracking.

  6. Solo dev friendly. Controllers are thin (~20 lines each). All business logic lives in shared command/query handlers. The duplication is minimal and deliberate.

Versioning Strategy

  • App API: Version when convenient. Deprecation is instant (ship app update + API change together). No long-term backward compatibility needed.
  • Public API: Version only on breaking changes. Support previous version for minimum 12 months. Communicate deprecations via response headers (Sunset, Deprecation).

Consequences

  • Each module’s Presentation layer has two controller directories (AppApi/, Api/)
  • API documentation (OpenAPI) is generated separately per surface
  • Existing /api/v1/ controllers must be migrated: current endpoints become AppApi, public endpoints are added incrementally
  • Deptrac rules remain the same — both controller sets depend on the Application layer

Migration

  1. Rename existing Controller/Api/ to Controller/AppApi/, update route prefix to /app/api/v1/
  2. Update security.yaml firewalls to separate app vs public
  3. Add Controller/Api/ back as the public API surface, starting with read-only endpoints
  4. Update API_GUIDELINES.md with dual-surface documentation