DISCLAIMER
NOTE:
This is a few notes regarding what (I think/hope) I have learned this week.PLEASE: Use it with a grain of salt! YOU HAVE BEEN WARNED!
PS: Any corrections, clarifications, or suggestions would be greatly appreciated! NO, SERIOUSLY!
OVERVIEW
💡 A pattern that splits your codebase into two hard zones:
ZONE
src/core/
- PURE functions only
- No I/O.
- No side effects.
ZONE
src/shell/
- NON-PURE functions only
- All
I/Olives here.- Causes side effects.
Why Bother?
Testability without ceremony:
Core tests need zero setup - no mocks, no DB, no async. Plain object literals in, assertions out. If a test needs beforeEach or await, the function is in the wrong layer.
Deterministic replay:
Pure functions mean you can log inputs at the shell boundary and reproduce any production bug exactly - no database state, no timing, no environment to reconstruct.
No framework lock-in at the core:
src/core/ has zero runtime dependencies. Switching from Express to Hono, Drizzle to Prisma, or Node to Bun touches only the shell. Business logic is untouched.
Parallel development:
Once core types and signatures are defined, shell and business logic can be built simultaneously. The contract is just data in, data out.
Free documentation:
Pure function signatures are the spec. canTransitionTo(current: TaskStatus, next: TaskStatus): boolean tells you everything - no layer-tracing required.
Safer code review:
Any PR touching only src/core/ cannot introduce a regression caused by I/O, timing, or external state. That's a meaningful trust boundary.
Incremental adoption:
No minimum viable structure. Extract one pure function from a messy handler and grow from there. Unlike DDD or clean architecture, it scales down.
How It Compares
vs. Clean Architecture / Hexagonal Architecture (Ports & Adapters)
Clean Architecture (Robert Martin) and Hexagonal Architecture (Alistair Cockburn) solve the same dependency problem - keep business logic independent of infrastructure - but through abstraction layers: interfaces, ports, adapters, and dependency injection containers.
FCIS gets there through data flow instead. No interfaces. No adapter classes. No DI framework. The core is isolated not by indirection, but because it literally only speaks in plain data types and pure functions.
--
Clean / Hexagonal vs. FCIS
Isolation mechanism
- Clean / Hexagonal: Interfaces + DI
- FCIS: Pure functions + data
Boilerplate
- Clean / Hexagonal: High
- FCIS: Minimal
Testability
- Clean / Hexagonal: Good (with mocks)
- FCIS: Better (no mocks needed)
Learning curve
- Clean / Hexagonal: Steep
- FCIS: Low
Best fit
- Clean / Hexagonal: Large teams, complex domains
- FCIS: Small-to-medium codebases
SUGGESTION
Clean Architecture: is powerful
FCIS is cheaper
Pick the one that matches your actual complexity. :)
vs. Domain-Driven Design (DDD)
DDD (Eric Evans, Domain-Driven Design, 2003) is a design philosophy - ubiquitous language, bounded contexts, aggregates, domain events. FCIS is an architectural pattern. They aren't competitors.
The key differences
DDD encourages rich domain models
— objects that encapsulate both data and behaviour
- (Aggregates, Entities, Value Objects with methods).
FCIS enforces the opposite:
- data and behaviour are always separate.
- A Task in FCIS is a plain type;
-
canTransitionTois a standalone function.
You can apply DDD thinking (bounded contexts, ubiquitous language) to an FCIS codebase. But you cannot use rich OOP domain objects in src/core/ without violating the purity constraint.
vs. Layered / N-Tier Architecture
The classic Service → Repository → Database stack organises code by technical role.
Business logic typically lives in a Service class that also coordinates I/O — calling repositories, dispatching events, logging.
The layers are present, but the boundary between logic and I/O is blurry.
FCIS makes that boundary a hard rule. The equivalent of a Service is split in two: pure logic goes to src/core/, orchestration goes to src/shell/. There's no "service that also does I/O" — that's the entire violation FCIS exists to prevent.
vs. Functional Programming (pure FP)
Languages like Haskell enforce purity at the type system level - impure code must be declared as such (e.g. IO monad). FCIS is a convention-based approximation of that discipline in TypeScript. There's no compiler enforcement of the core/shell boundary - it relies on discipline and linting.
The tradeoff is pragmatism: you get most of the reasoning and testability benefits of pure FP without leaving the TypeScript ecosystem or retraining your team.
Gary Bernhardt's Boundaries talk (2012) is the canonical introduction to this idea. His framing: push values to the edges, keep the centre pure.
MAIN CONCEPTS
The Core (src/core/)
-
ALLOWED:
- domain types
- validation
- business rules
- data transformations
- pure formatters
-
FORBIDDEN:
- async/await
- fetch
- fs.*
- console.log
- process.env
- new Date()
- DB calls.
-
EXAMPLES:
// ✅ Pure validation - returns Result, never throws export const validateTitle = (title: string) => { if (!title.trim()) return fail(Errors.validation('title', 'Cannot be empty')) return ok(title.trim()) } // ✅ Pure business rule - receives `now` as param, never calls new Date() internally export const isOverdue = (task: Task, now: Date) => { return !!task.dueAt && task.status !== 'done' && task.dueAt < now } // ✅ Pure workflow - all data arrives as params, returns computed result export const createTask = (project: Project, input: CreateTaskInput, now: Date) => { const title = validateTitle(input.title) if (!title.ok) return fail(title.error) return ok({ id: randomUUID(), ...input, createdAt: now, updatedAt: now }) }
The Shell (src/shell/)
💡 Every handler follows the same 5 steps - no exceptions:
- PARSE → extract input from args/env/stdin
- FETCH → read required data from DB/filesystem
- CALL → pass data to core, inspect Result
- ACT → persist what core returned
- OUTPUT → print to stdout/stderr
💡 If you're making a business decision in step 4,
move it to step 3.
// ✅ Thin handler - all decisions happen in core
export const taskDoneCommand = async (taskId: string) => {
// 1. PARSE
if (!taskId) { printError('ID required'); process.exit(1) }
// 2. FETCH
const task = await findTaskById(db, taskId)
if (!task) { printError('Not found'); process.exit(1) }
// 3. CALL CORE
const result = transitionTask(task, { taskId, toStatus: 'done' }, new Date())
if (!result.ok) { printError(result.error.message); process.exit(1) }
// 4. ACT
await updateTask(db, result.value.updatedTask)
// 5. OUTPUT
printSuccess('Task marked as done.')
}
ERROR HANDLING
💡 Use Result - never throw for expected failures.
type Result<T, E = AppError> = { ok: true; value: T } | { ok: false; error: E }
const ok = <T>(value: T): Result<T, never> => ({ ok: true, value })
const fail = <E>(error: E): Result<never, E> => ({ ok: false, error })
Expected failures are values. Thrown exceptions are for truly unexpected crashes only.
TESTING
💡 No setup. No mocks. No async.
// ✅ No setup. No mocks. No async.
it('rejects empty title', () => {
const result = validateTitle('')
expect(result.ok).toBe(false)
})
it('blocks invalid transitions', () => {
expect(canTransitionTo('done', 'todo')).toBe(false)
})
it('returns a new task without mutating the original', () => {
const task = makeTask({ title: 'Original' })
const updated = applyTaskUpdate(task, { title: 'Updated' }, new Date('2024-06-01'))
expect(updated.title).toBe('Updated')
expect(task.title).toBe('Original')
})
Bu, when NOT TO USE IT?
💡 NOTE:
- When the business logic is the I/O - e.g. a file watcher, a sync tool. The core/shell distinction collapses when there's nothing to separate.
- When the team is deeply invested in OOP/DDD and the retraining cost outweighs the benefit.
Pre-Commit Checklist
- [ ] No async/await in src/core/
- [ ] No imports from src/shell/ in src/core/
- [ ] No console.log, process.env, fs.*, fetch in src/core/
- [ ] new Date() only in shell, passed as param into core
- [ ] All expected failures return Result, nothing throws
- [ ] Shell handlers follow parse → fetch → call → act → output
- [ ] Core tests: no mocks, no DB, no async
REFERENCES
- Boundaries - the canonical source for FCIS thinking
- Clean Architecture - dependency rule, layers, and ports
- Hexagonal Architecture - ports & adapters original essay
- Domain-Driven Design - aggregates, bounded contexts, ubiquitous language
- Simplify & Succeed: Replacing Layered Architectures with an Imperative Shell and Functional Core - practical TypeScript walkthrough
- Railway Oriented Programming - the Result pattern in depth


Top comments (0)