Structural-First, Port-Driven Development Process
Purpose This SOP defines the invariant development process for projects with strong structural dependencies (monorepos, generators, ports/adapters, feature flags, observability). The goal is repeatability, low ambiguity, and safe iteration from day one.
Objective: A clean machine can reach “tests pass + app boots” with minimal manual steps.
Define canonical commands:
setup – install toolchain + depsdev up – start local servicestest – run baseline testsdev – run primary app(s)setup works without tribal knowledge.dev up starts all required local services.test passes without external dependencies.dev launches a runnable system (even if minimal).Objective: Define the shape of the system before writing behavior.
Define top-level topology:
Objective: Make architectural violations hard or impossible.
Objective: Local development mirrors production shape.
up, down, reset, seed.Use the OpenFeature SDK with Flipt (binary, not Docker) for vendor-neutral feature flag management.
1
2
3
4
5
# Install Flipt binary via mise -- version 2.4.0
mise use flipt
# Install OpenFeature SDK --
pnpm add @openfeature/server-sdk @openfeature/flipt-provider
Create infra/flipt/features/features.yaml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace: default
flags:
- key: new-dashboard
name: New Dashboard Experience
type: BOOLEAN_FLAG_TYPE
enabled: true
segments:
- key: beta-users
name: Beta Users
match_type: ANY_MATCH_TYPE
constraints:
- type: STRING_CONSTRAINT_TYPE
property: plan
operator: eq
value: beta
1
2
3
4
5
6
7
8
9
import { OpenFeature } from '@openfeature/server-sdk';
import { FliptProvider } from '@openfeature/flipt-provider';
await OpenFeature.setProviderAndWait(new FliptProvider({
url: process.env.FLIPT_URL ?? 'http://localhost:8080',
namespace: 'default',
}));
export const featureClient = OpenFeature.getClient();
targetingKey → Flipt’s entityId (required for rollouts)context map1
2
3
4
// Domain code uses port, not OpenFeature directly
export interface FeatureFlagsPort {
isEnabled(flag: string, context: FlagContext): Promise<boolean>;
}
flags-up, reads from features.yaml.FeatureFlagsPort, tests use FakeFeatureFlags.Objective: Debuggability exists before complexity.
Canonical Reference: ADR-029: Observability Stack Architecture
| Component | Role |
|---|---|
| OpenTelemetry | Instrumentation SDK (traces, metrics, logs) |
| OTel Collector v0.142.0 | Telemetry pipeline (receive, process, export) |
| OpenObserve v0.30.2 | Unified backend + dashboards (replaces Prometheus/Grafana) |
| Vanta | Continuous compliance automation (SOC2, ISO 27001) |
| Logfire v4.16.0 | Python-native structured logging with trace correlation |
All telemetry MUST include these resource attributes:
| Attribute | Description |
|---|---|
sea.domain |
Bounded context (e.g., governance) |
sea.concept |
Domain concept (e.g., PolicyRule) |
sea.regime_id |
Active compliance regime ID |
sea.platform |
Always sea-forge |
sea.domain, sea.concept, sea.regime_id present in all telemetry.Objective: Freeze boundaries early to reduce rework.
Objective: Humans design structure once; generators reproduce it forever.
Create custom generators for recurring patterns:
Ensure every generated project has standard targets:
Objective: Every layer has a test strategy before features land.
| Layer | TypeScript | Python | Purpose |
|---|---|---|---|
| Unit | Vitest 3.x | pytest 8.x | Fast, isolated tests for domain logic |
| Integration | Vitest + Testcontainers | pytest + testcontainers-python | Adapter tests with real DBs/services |
| E2E/Browser | Playwright 1.49.x | Playwright | Cross-browser UI tests |
| API Mocking | MSW 2.x | respx | Network-level request interception |
| Fixtures | @faker-js/faker | factory_boy | Deterministic test data |
Define the test pyramid explicitly:
Pitfall Avoidance: Vitest + Nx integration requires matching versions and proper plugin configuration.
1
2
3
# Nx 19+ includes @nx/vite with Vitest support
nx add @nx/vite
pnpm add -D vitest @vitest/coverage-v8 vite-tsconfig-paths
1
2
3
4
5
6
7
8
9
10
11
12
{
"plugins": [
{
"plugin": "@nx/vite/plugin",
"options": {
"buildTargetName": "build",
"testTargetName": "test",
"serveTargetName": "serve"
}
}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
environment: 'node', // or 'jsdom' for React
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
exclude: ['node_modules/', 'src/**/*.d.ts'],
},
// CRITICAL: Disable watch mode for CI
watch: false,
},
});
1
2
3
4
5
6
7
8
# Single project
nx test my-lib
# Affected only (CI)
nx affected -t test
# With coverage
nx test my-lib --coverage
1
2
3
4
5
# Install
pip install pytest pytest-cov pytest-asyncio testcontainers factory_boy
# Run
pytest tests/ -v --cov=src --cov-report=html
1
2
3
4
5
6
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
addopts = -v --tb=short
1
2
3
4
5
6
# Install
pnpm add -D @playwright/test
npx playwright install chromium
# Run
nx e2e my-app-e2e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'nx serve my-app',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
},
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { PostgreSqlContainer } from '@testcontainers/postgresql';
describe('UserRepository', () => {
let container: PostgreSqlContainer;
let connectionString: string;
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
connectionString = container.getConnectionUri();
});
afterAll(async () => {
await container.stop();
});
it('should save and retrieve user', async () => {
const repo = new UserRepository(connectionString);
// ...
});
});
1
2
3
4
5
6
7
8
9
10
11
12
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([{ id: 1, name: 'Test User' }]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
nx test, version matches @nx/vite.nx e2e, traces on failure.just test-python, async tests supported.Objective: Each increment is production-shaped and safe.
Lesson Learned: Infrastructure issues commonly derail implementation. Verify before coding.
just generator-checkjust flow-lintjust setupjust doctorCritical: Prefer just recipes. Use direct Nx invocation as fallback only.
Just Recipes (Preferred):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Bounded context (creates domain/ports/adapters layers)
just generator-bc <name>
just generator-bc <name> sea typescript # With explicit scope/lang
# Adapter pair (fake + real implementation)
just generator-adapter <name> <ctx> http
# API surface (routes, handlers)
just generator-api <name> <ctx>
# List available generators
just generator-list
# Build generators (required after changes to generators/)
just generator-build
Fallback (Direct Nx Invocation):
1
2
3
pnpm nx g @sea/generators:bounded-context <name> --scope=sea
pnpm nx g @sea/generators:adapter <name> --context=<ctx> --backend=http
pnpm nx g @sea/generators:api-surface <name> --context=<ctx>
After running generators:
tsconfig.base.json:
1
2
3
"@sea/<name>-domain": ["libs/<name>/domain/src/index.ts"],
"@sea/<name>-ports": ["libs/<name>/ports/src/index.ts"],
"@sea/<name>-adapters": ["libs/<name>/adapters/src/index.ts"]
pnpm add uuid && pnpm add -D @types/uuidFor bounded contexts requiring both Python and TypeScript:
| Layer | TypeScript Location | Python Location |
|---|---|---|
| Domain Types | domain/src/lib/types.ts |
domain/src/gen/types.py |
| Ports | ports/src/lib/*.port.ts |
ports/src/gen/ports.py |
| Adapters | adapters/src/lib/*.adapter.ts |
services/<ctx>/src/adapters/ |
| Tests | adapters/src/lib/*.spec.ts |
tests/<ctx>/test_*.py |
Python Service Structure:
1
2
3
4
5
6
7
8
services/<name>/
├── pyproject.toml # Dependencies (use poetry or pip)
├── Dockerfile # Container build
├── main.py # FastAPI entry point
└── src/
├── api/routes.py # HTTP endpoints
├── adapters/<name>.py # Real adapter implementation
└── config/settings.py # Pydantic settings
Lesson Learned: Create fakes before real adapters. This enables:
- Parallel development (UI can proceed while backend is built)
- Deterministic testing (no external dependencies)
- Contract verification (fake implements same port interface)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. Define the port first
export interface LlmProviderPort {
completeChat(request: ChatRequest): Promise<ChatCompletion>;
}
// 2. Implement the fake immediately
export class FakeLlmAdapter implements LlmProviderPort {
async completeChat(request: ChatRequest): Promise<ChatCompletion> {
// Deterministic response for testing
return {
id: `fake-${Date.now()}`,
content: `Echo: ${request.messages[0]?.content ?? ''}`,
// ...
};
}
}
// 3. Write tests against the fake
// 4. Then implement the real adapter
| Pitfall | Symptom | Prevention |
|---|---|---|
| Wrong generator prefix | “Cannot find generator ‘sea:adapter’” | Use @sea/generators:adapter |
| Missing path mappings | “Cannot find module ‘@sea/myctx-domain’” | Add to tsconfig.base.json |
| Generator type errors | TypeScript compilation fails | Use literal unions, not string |
| Missing @cqrs annotations | flow_lint.py fails | Add annotations before pipeline |
| Python imports fail | ModuleNotFoundError | Use pip install -e . in service dir |
Objective: Enable speed without permanent complexity.
| Type | Purpose | Typical Lifespan |
|---|---|---|
release |
Gate incomplete features | Days to weeks |
experiment |
A/B testing, metrics-driven | Weeks to months |
ops |
Circuit breakers, kill switches | Permanent |
tiering |
Entitlement, plan-based access | Permanent |
Objective: Fast feedback, safe merges, predictable releases.
Lesson Learned: Spec-first development means specs change before generated code. CI must accommodate this workflow.
| File Category | CI Behavior |
|---|---|
Only **/src/gen/** |
Determinism check runs — must match pipeline output |
docs/specs/** or generators/** |
Determinism check skipped — stale code expected |
| Both categories | Determinism check skipped — spec changes take precedence |
Workflow after spec PR merges:
1
2
just pipeline <context> # Regenerate from updated specs
git add -A && git commit -m "chore: regenerate from spec updates"
Objective: Prevent slow decay of system quality.
Use this as a literal checklist. If a phase isn’t satisfied, don’t move forward.
Pass if: clean clone → setup → dev up → test → dev works
Pass if: new dev knows where to start in <5 minutes
Pass if: architectural violations fail automatically
up / down / reset / seed commands existinfra/flipt/features/features.yaml defines all flags@openfeature/server-sdk + @openfeature/flipt-provider installedFeatureFlagsPort abstraction created for domain codeFakeFeatureFlags available for testsPass if: local mirrors production shape, flags work via OpenFeature
infra/otel/otel-collector-config.yaml)sea.domain, sea.concept, sea.regime_id)Pass if: “what broke?” is answerable immediately, all telemetry uses OTel
Pass if: boundaries are frozen before implementation
just generator-bc, just generator-adapter)just generator-build (fallback: pnpm nx run generators:build)just generator-bc <name> (fallback: pnpm nx g @sea/generators:bounded-context)string) for narrowed paramsPass if: adding a module is one command, generators compile cleanly
@nx/vite plugin)
vite.config.ts includes test blockvite-tsconfig-paths for monorepo path resolutionwatch: false for CIpytest.ini or pyproject.toml configpytest-asyncio for async testsplaywright.config.ts with webServer setupPass if: just test-ts and just test-e2e both pass
Pre-Implementation Checks:
just generator-check (fallback: pnpm nx run generators:build)just flow-lintjust setupjust doctortsconfig.base.json for new libsFor each feature:
gen/, TypeScript in lib/)Post-Generator Checklist:
tsconfig.base.json:
1
"@sea/<ctx>-domain": ["libs/<ctx>/domain/src/index.ts"]
pnpm add uuid && pnpm add -D @types/uuidpip install -e . runPass if: slice is production-shaped, reversible, and fakes exist for all ports
features.yaml commentsPass if: flags don’t accumulate, governance is automated
Pass if: releases are boring and repeatable
Pass if: quality improves over time