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):
| Surface | URL prefix | Auth | Audience |
|---|---|---|---|
| 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
-
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).
-
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). Noif isApiKeybranching in controllers. -
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.
-
Response shapes can differ. App API returns rich, nested data optimized for UI rendering. Public API returns flat, stable structures with explicit field allowlists.
-
Rate limiting and quotas differ. First-party clients get generous limits. Public API gets per-key rate limiting and usage tracking.
-
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 becomeAppApi, public endpoints are added incrementally - Deptrac rules remain the same — both controller sets depend on the Application layer
Migration
- Rename existing
Controller/Api/toController/AppApi/, update route prefix to/app/api/v1/ - Update security.yaml firewalls to separate app vs public
- Add
Controller/Api/back as the public API surface, starting with read-only endpoints - Update API_GUIDELINES.md with dual-surface documentation