Date: 2025-12-06 Status: Accepted
Refined by: ADR-011 (library/SDK packages use a simplified architecture) Partially superseded by: ADR-014 (Turborepo is replaced by Vite+ as the task runner; the monorepo layout and FCIS + Ports architecture remain in effect)
Decision Makers: Maintainer Tags: architecture, monorepo, turborepo, fcis, testing, modularity
Bedrock is TypeScript CLI for Roblox deployment. Requirements:
- Testability: Unit, integration, e2e at every layer
- Flexibility: Swap state backends (GitHub Gist → S3 → Cloudflare R2)
- Clarity: Contributors know where code belongs
- Multi-package: Open Cloud API client, CLI tool, docs website
- Maintainability: Clear boundaries prevent architectural drift
Current state: Single package structure, planning multi-package growth.
Constraints:
- Must support swappable backends without rewrites
- Core logic must be testable without mocks
- Open Cloud client should be standalone package
Use Turborepo monorepo with Functional Core, Imperative Shell (FCIS) + Ports architecture.
bedrock/
├── apps/
│ └── website/ # Vitepress docs
├── packages/
│ ├── open-cloud/ # @bedrock-rbx/ocale
│ └── cli/ # bedrock CLI
├── docs/ # Internal docs (ADRs, plans)
├── turbo.json
└── pnpm-workspace.yaml
packages/cli/src/
├── core/ # Pure functions (no I/O, business logic)
├── shell/ # Commands (I/O orchestration)
├── ports/ # Interfaces for swappable backends
├── adapters/ # Port implementations (GistBackend, S3Backend)
└── types/ # Shared TypeScript types
FCIS + Ports Pattern:
- Core: Pure functions, deterministic, zero I/O, easily tested
- Shell: Calls core functions, handles I/O (file reads, API calls)
- Ports: Interfaces defining contracts (StateBackend, RobloxApiClient)
- Adapters: Concrete implementations of ports (GistBackend, etc.)
- Zero-mock testing: Core logic tested with pure function calls (no mocking)
- Clear I/O boundaries: Shell does I/O, core is pure - never confused
- Trivial backend swaps: Implement port interface, swap adapter
- Contributor clarity: Folder structure answers "where does this code go?"
- Standalone packages:
@bedrock-rbx/ocaleusable independently - Incremental builds: Turborepo caches, parallelizes builds
- Package isolation: Changes in CLI don't affect Open Cloud client
- More folders: Slightly more structure than flat layout
- Learning curve: Team must understand FCIS (though concept is simple)
- Monorepo tooling: Adds Turborepo config, workspace management
- Port boilerplate: Every adapter must implement port interface
- Workspace management: pnpm workspaces + Turborepo config required
- Cross-package dependencies: Must manage
@bedrock-rbx/ocaleversioning - Build orchestration: Turborepo handles task ordering
Pros: Simpler setup, no workspace config, fewer build steps.
Rejected: Known requirement for multiple packages (Open Cloud client, CLI, docs). Migration to monorepo later is painful. Better to start with structure that supports growth.
Pros: Familiar to enterprise developers, proven pattern (controllers → services → repositories).
Rejected: Requires DI container, every test needs mocks, more boilerplate. FCIS achieves better testability with less ceremony for CLI scope.
Why not this: CLI tools benefit from pure functional core more than layered services. DI overhead not justified when ports + adapters give same flexibility.
Pros: Excellent flexibility, domain logic isolated from infrastructure, proven pattern.
Rejected: More ceremony than needed for CLI scope (application services layer, domain layer, extensive port definitions). FCIS + Ports provides same benefits (swappable backends, testability) with less overhead.
Why not this: Bedrock is CLI tool, not business domain application. Full hexagonal adds layers (application services, domain models) that don't map to CLI use case. FCIS simplified hexagonal for functional-first codebase.
Pros: Simple, fast to start, intuitive grouping (deploy/, plan/,
apply/).
Rejected: Poor testability (I/O mixed with logic), boundaries blur over time (where does shared logic go?). Doesn't meet testability requirements. Experience shows this degrades into spaghetti for CLI tools over time.
Why not this: Tested deployment tools in production. Feature folders lead to I/O mixed with logic, making unit tests require mocks. Boundaries erode as codebase grows.
turbo.jsondefines task pipelines (build,test,lint)- Caching for all tasks
- Parallel execution where dependencies allow
Core rules:
- No imports from Node.js I/O modules (
fs,http, etc.) - Functions take data, return data
- Side effects forbidden (no logging, no mutations)
Shell rules:
- Orchestrates I/O (read files, call APIs)
- Calls core functions for business logic
- Handles errors, logging, CLI output
Port interface example:
// ports/StateBackend.ts
export interface StateBackend {
read(): Promise<null | State>;
write(state: State): Promise<void>;
}
// adapters/GistBackend.ts
export class GistBackend implements StateBackend {
public async read(): Promise<null | State> {
/* ... */
}
public async write(state: State): Promise<void> {
/* ... */
}
}@bedrock-rbx/ocale: Zero dependencies on CLIbedrock(CLI): Depends on@bedrock-rbx/ocalewebsite: No code dependencies, imports examples for docs
- ADR-001: TypeScript with Bun - informs monorepo tooling choices
- Future: Plugin architecture (if needed) will use ports pattern
- Future: Additional state backends (S3, R2) will implement StateBackend port
- Functional Core, Imperative Shell - Gary Bernhardt
- Hexagonal Architecture - Alistair Cockburn
- Turborepo Documentation
- pnpm Workspaces
- Pattern tested in production CLI tools (Terraform, Pulumi use similar port-adapter patterns)