Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ All standard Spring Boot properties can be passed as command-line flags or envir
| `DB_USERNAME` | Database user |
| `DB_PASSWORD` | Database password |

#### RabbitMQ configuration (CQRS event bus)

| Environment Variable | Description |
|----------------------|-------------|
| `RABBITMQ_HOST` | RabbitMQ hostname (default: `localhost`) |
| `RABBITMQ_PORT` | RabbitMQ AMQP port (default: `5672`) |
| `RABBITMQ_USERNAME` | RabbitMQ user (default: `guest`) |
| `RABBITMQ_PASSWORD` | RabbitMQ password (default: `guest`) |

#### Event publisher

| Environment Variable | Description |
|----------------------|-------------|
| `FACTSTORE_EVENTS_PUBLISHER` | `logging` (default), `rabbitmq` (production CQRS), `inmemory` (tests), `none` |

#### HashiCorp Vault integration

| Environment Variable | Description |
Expand All @@ -260,6 +275,94 @@ All standard Spring Boot properties can be passed as command-line flags or envir
| `GITHUB_CLIENT_ID` | GitHub OAuth app client ID |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth app client secret |

---

## CQRS Deployment (Dual-Service Architecture)

For production, Factstore runs as two separate services sharing a RabbitMQ event bus:

### Network Topology

```
┌──────────────┐
│ Clients │
└──────┬───────┘
POST/PUT/DELETE │ GET
┌──────────────────┴────────────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Command :8080 │──── RabbitMQ ────►│ Query :8081 │
└───────┬───────┘ └───────┬───────┘
│ │
┌───────▼───────┐ ┌───────▼───────┐
│ PostgreSQL │ │ PostgreSQL │
│ (Write DB) │ │ (Read DB) │
│ :5432 │ │ :5433 │
└───────────────┘ └───────────────┘
```

### Docker Compose (recommended)

```bash
docker compose up --build
```

This starts:
- **postgres-command** — Write database (port 5432)
- **postgres-query** — Read database (port 5433)
- **rabbitmq** — Event bus (AMQP 5672, Management UI 15672)
- **backend-command** — Command service (port 8080)
- **backend-query** — Query service (port 8081)

### Event-Driven Synchronization

1. A command (POST/PUT/DELETE) arrives at the **Command service** (:8080)
2. The command handler persists the entity + appends a domain event to the event store
3. The `EventAppender` publishes the event to the `IDomainEventBus` (RabbitMQ)
4. The **Query service** (:8081) `RabbitMqEventConsumer` receives the event
5. The `ReadModelProjector` applies the event to the read database

### CLI Configuration

The CLI supports separate hosts for read and write operations:

```bash
# Configure with separate command and query hosts
factstore configure
# Or use flags:
factstore --host https://command.example.com --query-host https://query.example.com flows list
# Or environment variables:
export FACTSTORE_HOST=https://command.example.com
export FACTSTORE_QUERY_HOST=https://query.example.com
```

When `--query-host` is set, GET requests are routed to the query service and all other requests to the command service.

### Post-Deployment Verification

```bash
# 1. Verify command service health
curl -fs http://localhost:8080/actuator/health

# 2. Verify query service health
curl -fs http://localhost:8081/actuator/health

# 3. Create a flow via command service and verify it appears on query service
curl -X POST http://localhost:8080/api/v2/flows \
-H 'Content-Type: application/json' \
-d '{"name":"verify-cqrs","description":"Post-deployment verification"}'

# Wait for event propagation (typically < 1 second)
sleep 2

# 4. Read the flow from query service
curl -s http://localhost:8081/api/v2/flows | grep verify-cqrs

# 5. Verify RabbitMQ is healthy
curl -fs http://localhost:15672/api/healthchecks/node \
-u guest:guest
```

### Docker environment variable example

```bash
Expand Down
109 changes: 61 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ cd OpenFactstore
docker compose up --build
```

- **API** → http://localhost:8080
- **Command API** → http://localhost:8080
- **Query API** → http://localhost:8081
- **Swagger UI** → http://localhost:8080/swagger-ui.html
- **RabbitMQ Management** → http://localhost:15672 (guest / guest)
- **Grafana** → http://localhost:3000 (admin / changeme)

---
Expand Down Expand Up @@ -91,54 +93,59 @@ When a software artifact is built, a **trail** captures provenance metadata (Git

## Architecture

Factstore is built on **Hexagonal Architecture** (Ports and Adapters) with a **CQRS + Event Sourcing** split. The core business logic is fully isolated from external systems. Dependencies always point **inward**: adapters depend on ports, ports depend on the domain — never the other way around.
Factstore is built on **Hexagonal Architecture** (Ports and Adapters) with a **fully decoupled CQRS + Event Sourcing** design. The core business logic is fully isolated from external systems. Dependencies always point **inward**: adapters depend on ports, ports depend on the domain — never the other way around.

The **Write** path accepts commands via v2 REST controllers, validates business rules, persists state, and appends immutable domain events to an append-only **Event Log**. The **Read** path serves queries from optimised read models. An **Event Projector** can replay the event log to rebuild read-model state from scratch or catch up incrementally.
The **Command (Write) service** accepts mutations via v2 REST controllers, validates business rules, persists state, and appends immutable domain events to an append-only **Event Log**. Every state-changing event is published to a **Domain Event Bus** (RabbitMQ in production, in-memory in tests) for consumption by the Read side. The **Query (Read) service** consumes events from the bus, projects them into its own database, and serves queries from optimised read models.

```
┌─────────────────────────────────────────────────────────────────┐
│ Frontend (Vue 3 SPA) │
│ Browser ─► Vite Dev Server :5173 │
└──────────────────────────────┬──────────────────────────────────┘
│ HTTP / REST (Axios)
┌──────────────────────────────▼──────────────────────────────────┐
│ Backend (Spring Boot :8080) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ DRIVING ADAPTERS (Inbound) │ │
│ │ adapter/inbound/web/command/ (v2 Command Controllers) │ │
│ │ adapter/inbound/web/query/ (v2 Query Controllers) │ │
│ │ adapter/inbound/web/ (v1 REST Controllers) │ │
│ └────────────────┬───────────────────┬────────────────────┘ │
│ Commands │ │ Queries │
│ ┌────────────────▼───────────┐ ┌────▼────────────────────┐ │
│ │ COMMAND HANDLERS (Write) │ │ QUERY HANDLERS (Read) │ │
│ │ application/command/ │ │ application/query/ │ │
│ │ (FlowCommandHandler, …) │ │ (FlowQueryHandler, …) │ │
│ └──────┬──────────┬──────────┘ └──────────┬──────────────┘ │
│ │ save │ append event │ read │
│ ┌──────▼──────┐ ┌─▼────────────────┐ ┌─────▼──────────────┐ │
│ │ JPA Entity │ │ Event Store │ │ Read Repositories │ │
│ │ Repositories│ │ (IEventStore) │ │ (Read ports) │ │
│ └──────┬──────┘ └─┬────────────────┘ └─────┬──────────────┘ │
│ │ │ │ │
│ ┌──────▼──────────▼─────────────────────────▼──────────────┐ │
│ │ DRIVEN ADAPTERS (Outbound) │ │
│ │ adapter/outbound/persistence/ (JPA + EventStoreAdapter) │ │
│ └──────────┬──────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────▼──────────────────────────────────────────────┐ │
│ │ EVENT PROJECTOR (application/EventProjector) │ │
│ │ Replays event log → rebuilds read-model state │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────┬────────────────────────────────────────────────────┘
│ JDBC
┌─────────────▼────────────────────────────────────────────────────┐
│ PostgreSQL Database │
│ Entity tables (flows, trails, …) + domain_events (event log) │
└──────────────────────────────────────────────────────────────────┘
└──────────────────────┬──────────────────────┬───────────────────┘
POST/PUT/DELETE │ │ GET (reads)
┌──────────────────────▼───────────┐ ┌───────▼──────────────────────┐
│ COMMAND SERVICE (:8080) │ │ QUERY SERVICE (:8081) │
│ spring.profiles.active=prod │ │ spring.profiles.active=prod │
│ FACTSTORE_CQRS_ROLE=command │ │ FACTSTORE_CQRS_ROLE=query │
│ │ │ (read-only — rejects POST/ │
│ ┌─── DRIVING ADAPTERS ───────┐ │ │ PUT/PATCH/DELETE via filter)│
│ │ v2 Command Controllers │ │ │ ┌─── DRIVING ADAPTERS ───┐ │
│ │ v1 REST Controllers │ │ │ │ v2 Query Controllers │ │
│ └───────────┬────────────────┘ │ │ │ v1 REST Controllers │ │
│ └───────────┬────────────────┘ │ │ │ RabbitMQ Consumer │ │
Comment on lines +105 to +115
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README presents the query service as GET-only, but in the current codebase the v2 command controllers (e.g. FlowCommandController) are not gated on FACTSTORE_CQRS_ROLE/factstore.cqrs.role and will still be registered in the query container (same JAR + prod profile). That means the query host can accept POST/PUT/DELETE and mutate the read database if called directly. Consider documenting this caveat or (preferably) enforcing it by conditionally enabling command/query web adapters based on factstore.cqrs.role (or by blocking non-GET methods on the query role).

Copilot uses AI. Check for mistakes.
│ │ Commands │ │ └───────┬────────────────┘ │
│ ┌───────────▼───────────────┐ │ │ │ Queries │
│ │ COMMAND HANDLERS (Write) │ │ │ ┌───────▼──────────────┐ │
│ │ FlowCommandHandler, … │ │ │ │ QUERY HANDLERS │ │
│ │ EventAppender │ │ │ │ FlowQueryHandler, … │ │
│ └──────┬──────────┬─────────┘ │ │ │ ReadModelProjector │ │
│ save │ append │ publish │ │ └──────────┬───────────┘ │
│ ┌──────▼──┐ ┌─────▼────────┐ │ │ read │ │
│ │ JPA │ │ Event Store │ │ │ ┌──────────▼───────────┐ │
│ │ Repos │ │ (IEventStore)│ │ │ │ Read Repositories │ │
│ └────┬────┘ └──────────────┘ │ │ └──────────┬───────────┘ │
│ │ │ │ │ │
│ ┌────▼─────────────────────┐ │ │ ┌──────────▼───────────┐ │
│ │ JPA / EventStoreAdapter │ │ │ │ JPA Persistence │ │
│ └────┬─────────────────────┘ │ │ └──────────┬───────────┘ │
└───────┼──────────────────────────┘ └─────────────┼───────────────┘
│ JDBC ▲ AMQP │ JDBC
┌───────▼──────┐ ┌───────────┴───────┐ ┌───────▼──────┐
│ PostgreSQL │ │ RabbitMQ │ │ PostgreSQL │
│ (Write DB) │ │ (Event Bus) │ │ (Read DB) │
│ :5432 │ │ :5672 / :15672 │ │ :5433 │
└──────────────┘ └───────────────────┘ └──────────────┘
```

### Deployment Profiles

| Profile | Database | Event Bus | Use Case |
|---------|----------|-----------|----------|
| `prod` | Dual PostgreSQL | RabbitMQ | Production / staging |
| `test` | Dual H2 (in-memory) | In-memory (Spring events) | Integration tests |
| `local` | Single PostgreSQL | Logging (no-op) | Local development |
| *(default)* | Single PostgreSQL | Logging | Backward-compatible single-instance |

### Why Hexagonal Architecture + Event Sourcing?

- **Swap storage backends without touching logic.** Replace the H2 JPA adapter with a PostgreSQL or Vector DB adapter by writing a new `IFlowRepository` implementation — zero changes to `FlowService`.
Expand All @@ -160,18 +167,21 @@ com.factstore/
│ │ └── query/ ← Query handler interfaces (IFlowQueryHandler, …)
│ └── outbound/
│ ├── read/ ← Read-model repository interfaces
│ └── … ← Write-model repository + IEventStore port
│ └── … ← Write-model repository + IEventStore + IDomainEventBus ports
├── application/
│ ├── command/ ← Command handlers + EventAppender
│ └── query/ ← Query handlers
│ └── EventProjector ← Replays event log to rebuild read models
│ ├── EventProjector ← Replays event log to rebuild read models
│ └── ReadModelProjector ← Applies domain events to read DB entities
├── adapter/
│ ├── inbound/
│ │ └── web/
│ │ ├── command/ ← v2 Command REST controllers
│ │ └── query/ ← v2 Query REST controllers
│ │ ├── web/
│ │ │ ├── command/ ← v2 Command REST controllers
│ │ │ └── query/ ← v2 Query REST controllers
│ │ └── messaging/ ← RabbitMqEventConsumer + InMemoryEventListener
│ └── outbound/
│ └── persistence/ ← JPA adapters: entity repos + EventStoreAdapter
│ ├── persistence/ ← JPA adapters: entity repos + EventStoreAdapter
│ └── events/ ← Event bus adapters: RabbitMQ, InMemory, Noop
├── dto/
│ └── command/ ← Command DTOs and request objects
├── exception/ ← Domain exceptions and global error handler
Expand All @@ -187,11 +197,14 @@ com.factstore/
| Command Ports | `core/port/inbound/command/` | Command handler interfaces (`IFlowCommandHandler`, …) |
| Query Ports | `core/port/inbound/query/` | Query handler interfaces (`IFlowQueryHandler`, …) |
| Outbound Ports | `core/port/outbound/` | Repository interfaces + `IEventStore` (append-only event log) |
| Command Handlers | `application/command/` | Write-side use cases + `EventAppender` (dual-write: JPA entity + event log) |
| Command Handlers | `application/command/` | Write-side use cases + `EventAppender` (dual-write: JPA entity + event log + domain event bus) |
| Query Handlers | `application/query/` | Read-side use cases (query read-model repositories) |
| Event Projector | `application/` | `EventProjector` — replays event log to rebuild read-model state |
| Read Model Projector | `application/` | `ReadModelProjector` — applies domain events to read DB entities |
| Web Adapters | `adapter/inbound/web/` | REST controllers (v1 compat + v2 command/query split) |
| Messaging Adapters | `adapter/inbound/messaging/` | `RabbitMqEventConsumer` + `InMemoryEventListener` |
| Persistence Adapters | `adapter/outbound/persistence/` | JPA implementations of outbound ports + `EventStoreAdapter` |
| Event Bus Adapters | `adapter/outbound/events/` | `RabbitMqDomainEventPublisher`, `InMemoryDomainEventPublisher`, `NoopDomainEventBus` |
| DTO | `dto/` | Request/response objects and command DTOs |
| Exception | `exception/` | Custom exceptions and global error handler |
| Config | `config/` | CORS policy and OpenAPI/Swagger setup |
Expand Down
2 changes: 2 additions & 0 deletions backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ dependencies {
// Vault: Spring Vault core (used when vault.enabled=true)
implementation("org.springframework.vault:spring-vault-core:3.1.2")
implementation("org.springframework.boot:spring-boot-starter-graphql")
implementation("org.springframework.boot:spring-boot-starter-amqp")
testRuntimeOnly("com.h2database:h2")
testImplementation("org.springframework.amqp:spring-rabbit-test")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
Expand Down
Loading
Loading