Architecture
The IDK is built on a command-based architecture. Every operation (generating a key, exchanging a token, verifying a credential) is modeled as a command: a unit of work with typed input, typed output, and a well-defined identity. This page explains how commands, services, contracts, and the HTTP layer fit together.
Commands
A command is the fundamental building block. The base interface looks like this:
interface Command<Arg, SuccessResult, ErrorResult> {
val id: String
val isEnabled: Boolean
fun supports(args: Arg): Boolean
suspend fun execute(args: Arg): IdkResult<SuccessResult, ErrorResult>
}
Every command has an id, reports whether it isEnabled, checks whether it supports a given input, and executes that input to produce a result. The result is always an IdkResult, a sealed type that is either a success or an error. There are no thrown exceptions during normal operation.
Command Identity
Commands follow a structured naming convention: module.service.command. For example:
| Command ID | Module | Service | Action |
|---|---|---|---|
kms.keys.generate | kms | keys | generate |
oauth2.token.exchange | oauth2 | token | exchange |
did.resolution.resolve | did | resolution | resolve |
party.manager.create | party | manager | create |
This naming convention is not cosmetic. It drives several systems:
- Configuration overrides use the
cmd.*prefix to target specific commands, services, or entire modules. A configuration entry forcmd.kms.keys.*applies to every command in the KMS keys service. - Logging policies can be scoped to a command, a service, or a module. You might enable debug logging for
oauth2.token.*without affecting the rest of the system. - Metrics and tracing use the command ID as the span name, so distributed traces reflect the exact operation being performed.
Command Types
The IDK provides several specialized command types that extend the base interface for different use cases.
ServiceCommand
ServiceCommand<TInput, TOutput> is the workhorse for business logic. Most domain operations (key generation, token issuance, DID resolution) are ServiceCommand implementations. A service command takes a domain-specific input type and produces a domain-specific output type:
interface ServiceCommand<TInput, TOutput> : Command<TInput, TOutput, IdkError> {
override val id: String
override val isEnabled: Boolean
override suspend fun execute(args: TInput): IdkResult<TOutput, IdkError>
}
Service commands participate in the full command lifecycle: initialization extensions, execution extensions, interceptor chains, configuration overrides, and transport routing all apply.
HttpEndpointCommand
HttpEndpointCommand bridges the HTTP layer with the command system. It takes a GenericHttpRequest and returns a GenericHttpResponse, handling request parsing, delegation to a service command, and response serialization:
interface HttpEndpointCommand : Command<GenericHttpRequest, GenericHttpResponse, IdkError> {
val method: String // GET, POST, PUT, DELETE, etc.
val pathPattern: String // e.g., "/{keyId}" or "/token"
}
HTTP endpoint commands are grouped into adapters (see HTTP Layer below). Each endpoint command is a standalone unit that can be enabled, disabled, or overridden independently.
ChainCommand
ChainCommand runs a list of commands in sequence, passing the output of one as the input to the next. If any command in the chain fails, execution stops and the error propagates. This is useful for composing multi-step operations where each step must succeed before the next begins.
PipelineCommand
PipelineCommand is similar to ChainCommand but supports more complex workflows with branching, conditional steps, and error recovery. Pipelines are used internally for operations like identity verification, where the sequence of steps depends on the outcome of earlier steps.
Contracts
Commands can declare input and output contracts. A contract describes what the command expects and what it produces, using a structured schema.
interface CommandContract {
val inputContract: ContractSchema?
val outputContract: ContractSchema?
}
The input contract specifies the shape of valid input: required fields, types, constraints. The output contract specifies the shape of the command's success result. Together they make each command self-describing.
This has practical benefits:
- Validation: Input can be validated against the contract before execution, catching malformed requests early.
- Documentation generation: Contracts can be serialized into OpenAPI schemas or other formats, so API documentation stays in sync with the actual command signatures.
- Tooling: Development tools can inspect contracts to provide autocompletion, type checking, and request scaffolding.
Contracts are optional. Commands that do not declare a contract still work normally; they just lack the machine-readable description.
Services
While commands are the core execution unit, most application code does not call commands directly. Instead, it interacts with service interfaces that aggregate related commands into a convenient API surface.
The IDK provides service interfaces for each major domain:
| Service | Domain |
|---|---|
KeyManagerService | Key generation, storage, signing, verification |
OAuth2Client | Token requests, PKCE, DPoP, authorization code flows |
AuthorizationServerService | Authorization server operations (parse, verify, create for each grant type) |
Oid4vciIssuerService | Credential issuance, offers, deferred issuance |
DidResolver | DID document resolution |
DidManager | DID creation, update, deactivation |
TrustValidationService | Trust chain validation across frameworks |
KvStore | Key-value data storage |
BlobService | Binary object storage |
Under the hood, each service method delegates to a command. For example, when you call authorizationServerService.createAccessToken(...), the service locates and executes the corresponding ServiceCommand. This means every service call goes through the same lifecycle as a direct command invocation. Configuration overrides, interceptors, logging policies, and extension hooks all apply regardless of whether you call the service method or the command.
// These two approaches are equivalent:
// 1. Through the service interface (typical application code)
val token = session.graph.authorizationServerService.createAccessToken(input)
// 2. Through the command directly (lower-level, same lifecycle)
val token = createAccessTokenCommand.execute(input)
Services exist to provide a discoverable, IDE-friendly API. The command layer underneath provides the extensibility.
Extension Hooks and Interceptors
Commands support lifecycle hooks that let you inject behavior at specific points without modifying the command itself.
Initialization Extensions
ICommandInitExtension runs when a command is first initialized. Use it for setup tasks like validating configuration, registering metrics collectors, or pre-loading data that the command will need.
interface ICommandInitExtension<Arg, SuccessResult, ErrorResult> {
suspend fun onInit(command: Command<Arg, SuccessResult, ErrorResult>)
}
Execution Extensions
ICommandExecutionExtension hooks into the execution lifecycle. It provides callbacks for before execution, after successful execution, and after failed execution:
interface ICommandExecutionExtension<Arg, SuccessResult, ErrorResult> {
suspend fun beforeExecute(
command: Command<Arg, SuccessResult, ErrorResult>,
args: Arg
)
suspend fun afterExecuteSuccess(
command: Command<Arg, SuccessResult, ErrorResult>,
args: Arg,
result: SuccessResult
)
suspend fun afterExecuteError(
command: Command<Arg, SuccessResult, ErrorResult>,
args: Arg,
error: ErrorResult
)
}
Execution extensions are well suited for cross-cutting concerns: audit logging, telemetry, input sanitization, or rate limiting. Because they run around every command invocation, they apply uniformly across the system.
Interceptor Chains
CommandLifecycleInterceptorChain composes multiple extensions into an ordered chain. Interceptors can inspect and modify inputs, short-circuit execution (for example, to enforce a policy denial), or transform outputs. The chain runs in a defined order, so you can control precedence. A policy enforcement interceptor runs before an audit logging interceptor, for instance.
Interceptors are registered through dependency injection. Adding an interceptor to the DI graph is enough to activate it for all commands in the relevant scope.
HTTP Layer
The IDK exposes HTTP APIs through a command-backed adapter pattern. Rather than coupling endpoint handlers to a specific framework (Ktor, Spring, etc.), the IDK uses framework-agnostic types and routes requests to commands.
CommandBackedHttpAdapter
The CommandBackedHttpAdapter groups related HttpEndpointCommand instances and routes incoming requests to the matching command by HTTP method and path:
abstract class CommandBackedHttpAdapter(
override val id: String,
execution: SessionExecution,
protected val mount: HttpAdapterMount,
// ...
) : HttpAdapter {
protected abstract val endpointCommands: List<HttpEndpointCommand>
override suspend fun handleRequest(
request: GenericHttpRequest
): GenericHttpResponse
}
Each adapter declares a mount point (a server prefix and base path) and a list of endpoint commands. When a request arrives, the adapter finds the endpoint command whose method and path pattern match, extracts path parameters, and delegates to that command.
Built-in IDK services use this pattern. The OID4VCI issuer, OID4VP verifier, OAuth2 authorization server, and KMS REST API are all sets of HttpEndpointCommand instances grouped by adapter. For example, the OAuth2 authorization server adapter groups endpoint commands for /token, /authorize, /introspect, /revoke, /par, and /.well-known/oauth-authorization-server.
Request and Response Types
All HTTP commands work with GenericHttpRequest and GenericHttpResponse, plain data classes that carry the method, path, headers, query parameters, and body without any framework dependency:
data class GenericHttpRequest(
val method: String,
val path: String,
val pathParameters: Map<String, String>,
val queryParameters: Map<String, String?>,
val headers: Map<String, String>,
val bodySupplier: (() -> String?)?,
val bodyContent: GenericHttpBody
)
data class GenericHttpResponse(
val statusCode: Int,
val headers: Map<String, String> = emptyMap(),
val body: String? = null,
val bodyContent: GenericHttpBody = GenericHttpBody.Empty
)
Framework-specific bridges (like the Ktor server plugin) translate between these generic types and the framework's native request/response objects. This means the same adapter code works on Ktor, Spring Boot, or a serverless runtime without changes.
Adapter Dispatching
At startup, all adapters contributed to the DI graph are collected into an HttpAdapterCatalog. The catalog indexes adapters by their mount points and detects collisions (duplicate paths, overlapping endpoints). An HttpAdapterDispatcher routes each incoming request to the correct adapter based on path matching and specificity scoring.
This architecture means adding a new HTTP endpoint is a matter of writing an HttpEndpointCommand and adding it to an adapter's endpointCommands list. There is no manual route registration.
Error Handling
All commands return IdkResult<Success, Error>. This is a sealed type with two variants:
sealed class IdkResult<out S, out E> {
data class Ok<S>(val value: S) : IdkResult<S, Nothing>()
data class Err<E>(val error: E) : IdkResult<Nothing, E>()
}
Errors are typed and carry structured information: an error type enum, a human-readable message, an optional cause, and optional detail fields. The standard error type is IdkError, which covers common categories like NOT_FOUND_ERROR, ILLEGAL_ARGUMENT_ERROR, UNAUTHORIZED_ERROR, FORBIDDEN_ERROR, INVALID_STATE, and UNKNOWN_ERROR.
Error Mapping
CommandErrorMapper transforms command errors into context-appropriate responses. When a command fails inside an HTTP adapter, the error mapper converts the IdkError into an HTTP status code and response body. The mapping follows standard conventions:
| IdkError Type | HTTP Status |
|---|---|
ILLEGAL_ARGUMENT_ERROR | 400 |
UNAUTHORIZED_ERROR | 401 |
FORBIDDEN_ERROR | 403 |
NOT_FOUND_ERROR | 404 |
INVALID_STATE | 409 |
UNKNOWN_ERROR | 500 |
Protocol-Specific Error Renderers
Some protocols define their own error response formats. For example, OID4VCI requires errors to follow a specific JSON structure with error and error_description fields. Protocol-specific error renderers (like Oid4vciErrorRenderer) transform IdkError instances into the format required by the protocol specification, so clients receive standards-compliant error responses.
// Standard IDK error handling
when (val result = command.execute(input)) {
is IdkResult.Ok -> handleSuccess(result.value)
is IdkResult.Err -> when (result.error.type) {
IdkErrorType.NOT_FOUND_ERROR -> handleNotFound()
IdkErrorType.UNAUTHORIZED_ERROR -> handleUnauthorized()
else -> handleGenericError(result.error)
}
}
This pattern is consistent throughout the entire IDK. Whether you are calling a service method, executing a command directly, or handling an HTTP request, error handling always follows the same structure.
How the Layers Compose
The layering works like this:
- Services provide the developer-facing API. You call
authorizationServerService.verifyTokenRequest(...)and get back a typed result. - Commands do the actual work. The service delegates to a
ServiceCommandthat runs through the full lifecycle: extensions, interceptors, configuration overrides. - Contracts describe what commands expect and produce, enabling validation and documentation generation.
- HTTP adapters expose commands as REST endpoints. Each endpoint is an
HttpEndpointCommandgrouped into aCommandBackedHttpAdapter. The adapter handles routing; the command handles logic. - Extensions and interceptors add cross-cutting behavior (audit, policy, telemetry) without modifying the commands themselves.
- Error handling is uniform.
IdkResultflows from command to service to HTTP adapter, with mappers and renderers transforming errors into the right format at each boundary.
This architecture means that adding a new capability (a new key type, a new grant flow, a new verification method) follows the same pattern every time: define a command, wire it into a service, optionally expose it through an HTTP adapter, and let the existing infrastructure handle configuration, lifecycle, and error handling.
Key Abstractions
The IDK abstracts several cross-cutting concerns so that application code does not couple to specific implementations. Each abstraction follows the same pattern: a public interface in the -public module, one or more implementations in -impl modules, and DI contributions that wire the right implementation at startup. This means you can swap backends, providers, or strategies without changing the code that depends on them.
Configuration
The configuration system (ConfigService) abstracts property resolution across multiple sources (environment variables, YAML, properties files, cloud providers, databases). Application code calls configService.getProperty("key", Type::class) without knowing where the value comes from. The system supports scoped overrides (app, tenant, principal) and command-scoped overrides (module, service, command) so the same code can behave differently per tenant or per operation. See Configuration for details.
Key Management
The KeyManagerService abstracts cryptographic key operations across multiple KMS providers (software, Android Keystore, iOS Secure Enclave, AWS KMS, Azure Key Vault, REST-based). Application code calls keyManagerService.generateKeyAsync(...) or keyManagerService.createRawSignature(...) and the service routes to the correct provider based on the key's provider ID or the requested algorithm. Multiple providers can be active simultaneously, and keys from different providers can be used interchangeably. See Key Management for details.
Logging
The logging system is scope-aware and config-driven. Each DI scope (app, user context, session) has its own log manager and log services. Log messages automatically carry scope context (tenant ID, principal, session ID). Log policies can be set globally or overridden per module, service, or command through the configuration system. Multiple log providers (console, mobile buffer, custom) run simultaneously. See Logging for details.
Trust Validation
Trust validation abstracts multiple trust frameworks (X.509 certificate chains, ETSI trust lists, OpenID Federation, DID-based trust) behind a single TrustValidationService.validate() call. The service delegates to framework-specific validators contributed via DI and returns a unified TrustValidationResult. Application code does not need to know which trust framework applies; the validators evaluate the input and report whether it is trusted. See Trust for details.
Identity Resolution
The IIdentifierService abstracts the mapping from cryptographic identifiers (DIDs, X.509 certificates, JWKs) to public key material. When verifying a signature, the IDK resolves the signer's identifier to a public key through a chain of resolution strategies (DID resolution, JWKS fetching, X.509 chain extraction, cnf claim parsing). This decouples signature verification from the specific identifier format. See Identifier Resolution for details.
Data Storage
The key-value store (KvStore) and blob store (BlobService) abstract data persistence across backends (in-memory, filesystem, SQLite/Kottage, HTTP remote). Application code uses the same interface regardless of backend. Both stores support scope-based tenant isolation and pluggable backends via DI contributions. See Data Storage for details.
Commands and Services: Two Levels of Access
When you are building an application, you typically use service interfaces. They provide a clean, typed API:
// Service-level access (typical application code)
val keyPair = keyManagerService.generateKeyAsync(
GenerateKeyArgs(algorithm = SignatureAlgorithm.ECDSA_SHA256)
)
When you need to customize behavior (add interceptors, override configuration per command, build custom pipelines), you work with commands directly. The same operation is available as a command:
// Command-level access (for customization, pipelines, interceptors)
val command = session.getCommand<GenerateKeyCommand>()
command.execute(GenerateKeyArgs(algorithm = SignatureAlgorithm.ECDSA_SHA256))
Both paths go through the same lifecycle. The service is a convenience layer; the command is the execution unit. Most developers never need the command level directly, but it is there when you need fine-grained control.