Build & Deploy
The Maven plugin
aperture-maven-plugin runs during the generate-sources phase of every Maven build. Add it to your project's pom.xml:
<plugin>
<groupId>com.itsjool.aperture</groupId>
<artifactId>aperture-maven-plugin</artifactId>
<executions>
<execution>
<goals><goal>generate</goal></goals>
</execution>
</executions>
</plugin>Default configuration (all paths relative to ${project.basedir}):
| Parameter | Default |
|---|---|
manifestDirectory | manifests/ |
outputDirectory | target/generated-sources/aperture/ |
lockDirectory | .aperture.lock/ |
generatedResourcesDirectory | target/generated-resources/ |
Generated CLI (optional)
Aperture can generate a fully-featured CLI for your API from your manifests, including entity CRUD commands, auth, profiles, and optional GraalVM native binary support. It is off by default.
See the Generated CLI guide for full setup instructions, GraalVM native builds, platform targeting, and configuration reference.
mvn clean deletes target/ entirely. Regenerate with mvn package or mvn verify.
Building
# Full build: validates manifests, generates code and changesets, compiles, tests, packages
mvn verify --no-transfer-progress
# Faster: only the core runtime and engine modules (no demo)
mvn verify -pl core/aperture-core-runtime,core/aperture-core-engine,core/aperture-changeset \
-am --no-transfer-progress
# Demo component tests (requires Docker)
mvn verify -pl demos/aperture-demo --no-transfer-progressThe build fails if:
- Any manifest references an undefined entity, role, or policy
- A field is removed or type-changed without a corresponding API version bump
- A global entity references a tenant-scoped entity in POOL mode
Schema automation
The changeset generator produces two Liquibase XML files per build:
aperture-schema.xml: full DDL
Contains createTable changesets for every entity, plus all foreign key constraints and indexes. Used when deploying to a fresh database. Liquibase runs this file idempotently, skipping change sets that have already applied.
aperture-incremental.xml: diff-only delta
Contains only the changes between the current manifest and the committed lock files. Run on every deploy against an existing database.
The four diff categories and what they generate:
| Change | Generated changeset | Notes |
|---|---|---|
| New field added | addColumn with MARK_RAN precondition | Idempotent; skipped if column already exists |
Field renamed (renamedFrom:) | renameColumn | No data loss |
| Field removed | dropColumn with context="pending" | Not applied automatically (see Deferred drops) |
oneof field added | addColumn for {field}_type and {field}_id, plus a composite index | No FK constraint, because the target table depends on the row; POOL mode prefixes the index with aperture_tenant_id |
OneOf member added | Domain-model lock update only | Compatible model expansion |
OneOf member removed | Build fails as breaking without a versioned rollout | Existing rows may still reference that member |
| New ManyToOne field | addForeignKeyConstraint | Added after both tables exist |
Deferred drops explained
When you remove a field from a manifest, the changeset generator writes a dropColumn changeset tagged context="pending":
<changeSet id="deferred-drop-aperture_invoices-old_field"
author="aperture"
context="pending">
<dropColumn tableName="aperture_invoices" columnName="old_field"/>
</changeSet>This changeset is not applied during normal deployments because the pending context is not active. To apply it deliberately:
mvn liquibase:update -Dliquibase.contexts=pendingThis safety mechanism means you can remove a field from the manifest, deploy the new version (which stops reading/writing the column), and only drop the column when you are confident the data is no longer needed. Accidental DROP COLUMN with data loss is not possible through normal deployments.
renamedFrom: zero-downtime renames
To rename a field without data loss, add renamedFrom: to the new field definition:
fields:
phone_number: # new name
type: string
since: 2
renamedFrom: phone # old column nameThe diff engine generates a renameColumn changeset instead of a dropColumn + addColumn pair. No data is lost.
The lock files
After a successful build, the Maven plugin writes updated JSON snapshots to .aperture.lock/ (one file per entity, plus a domain-model lock when model-level constructs such as OneOf are present). Commit these alongside every manifest change.
Lock files serve two purposes:
- They tell the diff engine what the database currently looks like
- They are the schema migration record. Without them, the next build treats every entity as new and regenerates all changesets from scratch
Never delete lock files unless you intend to recreate the entire schema from scratch.
OneOf declarations are stored in 1-domain-model.json (or the matching API-version prefix). That file is part of the migration record. Without it, the diff engine cannot tell whether a member was added safely or removed as a breaking change.
Manual migration manifests
For data migrations, backfills, and custom SQL that doesn't map to a manifest change, use the Migration kind:
apiVersion: aperture.itsjool.com/v1
kind: Migration
metadata:
name: backfill-phone-number
spec:
position:
after: create-customer-table # positions the changeset in the changelog
sql: |
UPDATE aperture_customers
SET phone_number = '000-000-0000'
WHERE phone_number IS NULL;
rollback: |
UPDATE aperture_customers
SET phone_number = NULL
WHERE phone_number = '000-000-0000';Migration manifests are included in the root changelog at the position specified by position.after. They are applied once, tracked by Liquibase, and have a rollback block for safe downgrade.
API versioning
Fields can be gated to a specific API version using since::
fields:
phone_number:
type: string
since: 2 # invisible to v1 clientsThe code generator produces versioned entity classes such as V1Customer and V2Customer. Requests to /api/v1/customers use V1Customer (no phone_number). Requests to /api/v2/customers use V2Customer (with phone_number). Old clients continue to work without modification.
API versions and their status (ACTIVE or SUNSET) are declared in ApiVersionConfig manifests:
apiVersion: aperture.itsjool.com/v1
kind: ApiVersionConfig
spec:
versions:
- name: "1"
status: ACTIVE
- name: "2"
status: ACTIVERemoving a version (or setting it to SUNSET) requires an API version bump to avoid breaking changes at build time.
Every entity is reachable over GraphQL as well as JSON:API, at /graphql/{version}. GraphQL is off by default, and gated on the same path-based versioning as the REST endpoints. Nested relationship traversal and mutations both work against the same permission and manifest model as REST for ordinary entity relationships. oneof fields are a JSON:API/CLI/MCP contract in this release, not part of the generated GraphQL schema. See GraphQL configuration for the switch and limitation.
Docker deployment
Aperture apps are standard Spring Boot applications. Package with Maven, copy the JAR into a runtime image.
Dockerfile
# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY . .
RUN mvn package -DskipTests --no-transfer-progress
# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]Environment variables
All Aperture configuration is environment-variable-driven. Required at runtime:
| Variable | Description |
|---|---|
DB_URL | JDBC URL, for example jdbc:postgresql://postgres:5432/myapp |
DB_USER | Database username |
DB_PASS | Database password |
APERTURE_JWT_SECRET | HMAC signing key; minimum 32 bytes |
APERTURE_ENCRYPTION_KEY | AES-256 encryption key; 32-byte Base64 (openssl rand -base64 32) |
APERTURE_HOOKS_SECRET | Shared secret for hook request signing |
APERTURE_BOOTSTRAP_ADMIN_PASSWORD (seen in the demo compose files) is not a general Aperture variable. See Bootstrap admin for what actually consumes it.
Schema migration on startup
Liquibase runs automatically on startup. Against a fresh database it applies the full DDL from aperture-schema.xml; against an existing database it runs only the incremental changesets from aperture-incremental.xml. The /actuator/health endpoint reports DOWN until migrations complete, so configure your orchestrator health check accordingly:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
retries: 15
start_period: 90sMinimal docker-compose.yml
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${DB_PASS}
POSTGRES_DB: myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 10s
retries: 5
api-server:
image: my-aperture-app:latest
environment:
DB_URL: jdbc:postgresql://postgres:5432/myapp
DB_USER: myapp
DB_PASS: ${DB_PASS}
APERTURE_JWT_SECRET: ${APERTURE_JWT_SECRET}
APERTURE_ENCRYPTION_KEY: ${APERTURE_ENCRYPTION_KEY}
APERTURE_HOOKS_SECRET: ${APERTURE_HOOKS_SECRET}
depends_on:
postgres:
condition: service_healthy
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
retries: 15
start_period: 90sFor a complete worked example including a hook service, database seeder, and distributed tracing, see the Billing Demo.