Architecture · v0.19.0

How the factory is technically built.

From the UI to the adapters talking to the coding CLIs. Hexagonal per module, locally runnable, no cluster, no service mesh. Look up which screw sits where, and why.

hexagonal · ports & adapters 7 run phases ADR-documented

Goal of the architecture

The platform is a control plane for AI-assisted software development. It bundles four tasks into one web app: project capture, artifact generation, run orchestration and quality assessment. Coding CLIs (Claude, Codex, Gemini, Aider) are invoked as subprocesses; their output is structured into the database and made live-visible in the UI.

Two principles, deliberately not buzzword-loaded in V1:

  • Hexagonal per module. Every business module (e.g. run, wizard, settings) has separate domain, application, web and infrastructure layers. Domain depends on nothing.
  • Local execution. One Spring Boot process + Postgres in Docker. No cluster, no Kafka, no service mesh.
Browser → Spring MVC + Thymeleaf → Application services → Domain model
                                                              ↓
                       PostgreSQL ← Repositories ← Adapters
                       (Claude / Codex / Gemini / Aider / Mock / Git / FS)

Layers and modules

Inside the Maven module app all business modules live under io.softwarefabrik.app.<module>. Each module has at most four sub-packages:

  • domain/ — entities, value objects, repository interfaces, business services. No Spring, JPA or web annotations.
  • application/ — use-case services. Transaction boundaries (@Transactional).
  • web/ — Spring MVC controllers, Thymeleaf bindings, DTOs, SSE endpoints.
  • infrastructure/ — JPA repositories, external adapters, filesystem access, scheduled jobs.

Dependency rules

  • domain depends on nothing.
  • application depends only on domain.
  • web depends on application, not directly on domain.
  • infrastructure implements domain ports.
  • No cross-grabbing into another module's domain.

ArchUnit tests pin these rules. A wrong dependency turns CI red.

Modules in v0.19.0

agentauditcommonconductorexecutiongitlicenseobservabilitypolicyprojectdefinitionpromptqualitygatereviewrunsecretssecuritysettingsteamvalidationwebwizard

Modules in detail

ModulePurposeMain classes / routes
agentAgent roles + preferredModelAgentDefinition, AgentRoleService, /agents
auditAppend-only audit logAuditEvent, table audit_event
commonShared utilitiesID generator, clock abstraction
conductorPlugins/Skills sync, AGENTS.md writer (v0.6.0+)PluginCatalog, SkillsCatalog, ConductorWorkspaceWriter
executionRun execution, sandbox factory, stream parserExecutionAdapterRegistry, LocalProcessSandbox + ContainerProcessSandbox, ExecutionSandboxFactory, ClaudeStreamJsonParser, TokenEstimator
gitGit in the workspace, inline diff before approvalGitService.diffSeit() (v0.8.0), shell-out to git
licenseTier licenses, JWT lease/license, /admin/license
observabilityPrometheus metrics, observability level 1 (since v0.9.0)ObservabilityConfig, Micrometer registry at /actuator/prometheus (ROLE_ADMIN)
policyApproval policiesApprovalDecision
projectdefinitionProject editor/projects, /projects/{id}/edit
promptMarkdown artifact generationTemplates under resources/prompts/
qualitygateVerdict aggregation/runs/{id}/quality-gate
reviewReviewer implementationsaider-review, claude-review, security, architecture-reviewer, hallucination-review
runRun model, RunTemplate (v0.7.0)Run, RunPhase, RunOrchestrationService, RunTemplate, /runs
secretsAPI-key encryptionAES-GCM, /integrations
securitySpring SecuritySecurityConfig
settingsGlobal defaults/einstellungen, table app_setting (V9); setting execution.sandbox.variant since v0.7.0
teamTeam compositionSerialised into AGENTS.md
validationBuild validationmvn verify, npm run build
webUI cross-cuttingLayouts, theme toggle, dashboard
wizardFour-step assistant with cost estimateWizardController, WizardService, WizardCostEstimator, TemplateRegistry (6 templates); tables wizard_draft (V12), version_cache (V13)

Data flow: from wizard to quality gate

Wizardwizard_draft
Projectproject_def
Artifactsprompt_artifact
Runrun
run_phase
run_log
token_usage
Quality Gatequality_gate_run
  1. Wizard collects answers in wizard_draft (survives browser reload and server restart).
  2. ProjectDefinition holds all editor fields. Status DRAFTREADY after artifact generation.
  3. Artifacts are generated by PromptAssemblyService, editable.
  4. Run orchestrates phases asynchronously (@Async), UI listens via SSE.
  5. Quality Gate invokes reviewers in parallel, aggregates to the verdict.

Iterative SDLC loop

The data flow above describes a single greenfield run up to the quality gate. Beyond the single run, the platform closes an iterative loop over a persistent workspace — the same project copy is built further across multiple runs, freshly created or imported from an existing repository via repo-import. Eight steps interlock:

  1. Plan. A plan run produces Markdown plans under plans/*.md in the workspace; from these a backlog of individually activatable items emerges.
  2. Select / activate. A backlog item is activated in the backlog and becomes the objective of the next build run.
  3. Build. A build run runs on its own branch (sdlc/run-…) in the persistent workspace, instead of directly on the base branch.
  4. Self-correction. If the build fails, the run engine repeats the correction automatically and in a bounded way: the run switches from NEEDS_CORRECTION back to RUNNING until the build is green or the retry limit is reached.
  5. Quality gate in the pipeline. The qualitygate module runs as a pipeline step in three modes: off, advisory (reports only) or blocking (stops delivery on a red verdict).
  6. Deliver. With a configured Git remote and a stored GitHub token the git module pushes the branch and opens a pull request; without remote/token it performs a local merge into the base branch.
  7. Learn. Findings land in project memory and flow as context into follow-up runs.
  8. Continue. Optionally the platform produces auto follow-up proposals for the next step — closing the loop back to step 1.

Persistence: Postgres + Flyway

Schema changes exclusively through Flyway migrations under app/src/main/resources/db/migration/. Every migration numbered (V<n>__<name>.sql), immutable after production.

MigrationContent
V1Initial schema: project_definition, agent_definition, team, run, run_phase
V2run_log, token_usage_event
V3approval_policy, approval_decision
V4integration_secret (encrypted)
V5audit_event
V6quality_gate_run, review_finding
V7license, license_lease
V8build_result
V9v0.4.0: app_setting with scope column
V10Run tags and quick-start markers
V11v0.4.0: agent_preferred_model
V12v0.4.0: wizard_draft
V13v0.4.0: version_cache (Java-computed staleness, no DB computed column — H2 test profile stays functional)
V14v0.6.0: extended agent_definition for conductor (mission, active skills)
V15v0.7.0: run_template — adapter, team, objective, optional project scope, audit fields

Sandbox variants (ADR-0011)

Every run gets its own workspace — a local directory under SOFTWAREFABRIK_WORKSPACES_ROOT/<project-slug>/<run-id>. The adapter is launched in that directory as its own process.

Local (default)

LocalProcessSandbox sets environment variables cleanly per process (no System.setenv at platform level), terminates processes hard on cancel/timeout (destroyForcibly()), writes stdout and stderr line-by-line into run_log plus SSE stream.

Container per run (v0.7.0, ADR-0011 variant B)

ContainerProcessSandbox starts every agent in an ephemeral Docker or Podman container with:

  • --cpus 2 --memory 4g --pids-limit 512 as resource cap
  • --read-only root + /tmp as tmpfs
  • --network=none as default
  • Bindmount on the workspace, -w /workspace

Enabled via setting execution.sandbox.variant=container; if Docker is missing from the PATH, ExecutionSandboxFactory falls back to the local variant with a log warning.

Security & CVE hygiene

  • Bootstrap admin is only created when SOFTWAREFABRIK_ADMIN_PASSWORD is explicitly set. No default passwords.
  • BCrypt for passwords, CSRF on every state-changing endpoint.
  • API keys encrypted with AES-GCM (master key from SOFTWAREFABRIK_SECRETS_MASTER_KEY) in DB.
  • Trivy and OWASP scans run per CI build. .trivyignore with documented exceptions.
  • pgJDBC 42.7.11 (CVE-2026-42198 fix). docker-compose.yml pins Postgres to 127.0.0.1:5432.

Observability

Since v0.9.0 the platform exports Prometheus metrics (observability level 1). The Micrometer registry sits at /actuator/prometheus and serves JVM, HTTP and DB-pool metrics. Main class: ObservabilityConfig.

  • The endpoint is restricted to ROLE_ADMIN and scraped via HTTP Basic — no open metrics surface.
  • A shared application label separates multiple instances in the same Prometheus.
  • Deliberately level 1: no tracing pipeline, no bundled Grafana stack.

Version cache and @Scheduled jobs

So the wizard can always pre-fill current stable versions, the platform keeps a cache of version lookups. Two @Scheduled jobs keep it fresh:

  • Daily at 03:00 a job refreshes the version cache.
  • Daily at 04:00 a second job deletes expired wizard_draft rows (TTL 30 days).

Lookups follow the strategy pattern: a VersionLookupClient interface, three implementations:

  • MavenCentralLookupClient — queries search.maven.org/solrsearch/select for Java libraries (Spring Boot, ArchUnit, OWASP Dependency-Check).
  • GithubReleasesLookupClient — fetches the latest release tag from the GitHub API (e.g. Trivy CLI).
  • NpmRegistryLookupClient — queries registry.npmjs.org for Node packages (Playwright).

Cache keys are functional constants, not technical paths — e.g. spring-boot.3.x, archunit.latest, owasp.dependency-check.latest, trivy.cli.latest, playwright.npm.latest. That lets us swap the source without breaking consumers. Staleness is computed in Java (threshold 25 hours, a buffer over the 03:00 job): now - refreshed_at > 25h — deliberately not a Postgres GENERATED column, so the H2 test profile keeps working.

Admins inspect the cache at /einstellungen/wizard/versions: each row shows cache key, returned value, refreshed_at, last_error and a Stale badge; a "Refresh now" button triggers a synchronous lookup for that key.

Settings architecture

The settings module is the platform's control panel. Three design choices work together:

  1. Scope hierarchy: every setting belongs to a scope (GLOBAL, USER, PROJECT). SettingService.resolve(key, context) searches narrow-to-wide: PROJECT first, then USER, then GLOBAL, then YAML.
  2. 5-minute TTL cache: resolved values are cached, so a resolve is essentially free 99 % of the time. UI changes don't hard-invalidate — at most 5 minutes later the new value is live, no app restart.
  3. Audit trail: every write to app_setting creates an audit_event with subject, key, old value, new value.

Concrete settings managed today:

KeyMeaningTypical value
workspace.rootRoot directory for all run workspaces/var/softwarefabrik/workspaces
git.user.nameGit author for auto-commitsSoftware Factory Bot
git.user.emailGit emailbot@softwarefabrik.local
execution.adapter.defaultDefault execution adapterclaudecode
execution.model.claudecodeDefault model for Claude Codeclaude-sonnet-5
execution.model.codexDefault model for Codexgpt-5
execution.sandbox.variantSandbox variant (local / container)local
budget.tokens.dailyDaily token cap2000000
budget.threshold.softSoft threshold in percent (warns only)80

Live streaming and SSE

The run detail page streams three things live from the backend to the browser: logs, phase updates and token counter. Instead of polling the platform uses Server-Sent Events (SSE) — an open HTTP stream that pushes one-way messages to the browser.

  • Heartbeat: every 20 seconds an empty :keepalive comment — keeps the connection alive and exposes dead TCP sockets quickly.
  • Auto-reconnect: the browser EventSource reconnects on its own; the platform sets retry: 5000 (5 s).
  • Lost-tail replay: on reconnect the server first replays the last 50 log entries so no gap appears.
  • Backpressure: each SSE session has a bounded buffer; if the browser falls behind the connection is closed cleanly instead of letting the heap grow.

Endpoints sit at /runs/{id}/stream/logs, /runs/{id}/stream/phases and /runs/{id}/stream/tokens. If a reverse proxy blocks HTTP streaming (some corporate setups do), the UI falls back to a 2-second polling loop with the same payload.

Test strategy and coverage gate

Tests are not optional; they are part of the architecture. Three pillars:

  • Unit tests: per service class, fast, no Spring context, Mockito-based.
  • Integration tests: with Spring Boot test slices (@WebMvcTest, @DataJpaTest) against H2.
  • ArchUnit tests: pin layer and module boundaries. Reach from web straight into infrastructure and a test fails.

Coverage gate: JaCoCo ≥ 81 % branch / ≥ 85 % line. A service merged without tests doesn't pass mvn verify — intentionally strict. CI pipeline (.github/workflows/ci.yml): build + test + ArchUnit + JaCoCo + OWASP Dependency-Check + Trivy file scan. OWASP runs non-blocking without an NVD key, Trivy only in its dedicated job.

What is deliberately NOT in the architecture

What the platform omits is as important as what it does. These omissions keep V1 readable and extensible:

  • Tenant foundations, not full multi-tenancy: project-boundary isolation, RBAC and SSO are in place; full audit-grade multi-tenancy (separate policy/audit/reports per tenant) is still to come — until then, one instance per tenant is recommended. See Known Limitations.
  • No own web IDE: the platform doesn't open a code editor in the browser. You work on the workspace with your local tools (VS Code, IntelliJ, …).
  • No own build server: mvn verify or npm run build run inside the workspace, not on a build cluster.
  • No own LLM hosting: the platform only invokes coding CLIs (Claude Code, Codex, Gemini, Aider). Air-gap setups go local CLI + local LLM (e.g. Ollama).
  • No plugin API for external adapters: in V1 adapters are added as Java classes in the codebase, not loaded as plugins. A plugin API is on the backlog (ADR-0014).

License and identity stack

Beside the product backend there is a separate stack for identity and licensing, decoupled from the platform DB:

  • Keycloak as the OIDC provider (users, roles, device authorization grant).
  • License service as a Spring Boot microservice with its own PostgreSQL.
  • Lease JWT signed with EdDSA (Ed25519)not RS256 — with embedded per-tier limits.
  • Offline verification in the client against the public key at https://license.softwarefabrik.io/api/v1/pubkey, fail closed on lease expiry.

The three tiers are Community, Professional and Enterprise. Community needs no account registration (lease issued via email + device-fingerprint hash). Lease duration depends on the tier:

Tier / variantLease duration
Community7 days
Professional30 days
Enterprise Cloud30 days
Enterprise Self-Hosted90 days
Enterprise Air-Gap365 days

The lease determines which adapters may be used and how run creation and team size are limited. Details in the license model.