--- Grok's suggestion (to give an idea of the FC/IS architecture on the backend in JS/TS):
Here's a revised version where the functional core is kept completely pure — no Effect, no Task, no monads of any kind inside the core business logic. We still follow Functional Core / Imperative Shell, but the core now returns plain values + Either-like results (using simple discriminated unions).
// =============================================
// FILE: src/core/types.ts
// =============================================
export interface User {
readonly id: string;
readonly email: string;
readonly name: string;
readonly isActive: boolean;
}
export interface CreateUserInput {
readonly email: string;
readonly name: string;
readonly password: string; // plain text — hashing belongs in shell
}
export type CreateUserError =
| { readonly _tag: "EmailAlreadyExists"; readonly email: string }
| { readonly _tag: "InvalidEmailFormat"; readonly email: string }
| { readonly _tag: "WeakPassword"; readonly reason: string }
| { readonly _tag: "UnexpectedError"; readonly message: string };
export type CreateUserResult =
| { readonly _tag: "Success"; readonly user: User }
| { readonly _tag: "Failure"; readonly error: CreateUserError };
// =============================================
// Pure functional core — NO side effects, NO Effect, NO async!
// FILE: src/core/user-service.ts
// =============================================
const isValidEmail = (email: string): boolean =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const isStrongPassword = (password: string): boolean =>
password.length >= 8 &&
/[A-Z]/.test(password) &&
/\d/.test(password);
export const createUserLogic = (
input: CreateUserInput,
emailAlreadyTaken: (email: string) => boolean // ← pure function passed from shell
): CreateUserResult => {
// 1. Input validation
if (!isValidEmail(input.email)) {
return {
_tag: "Failure",
error: { _tag: "InvalidEmailFormat", email: input.email }
};
}
if (!isStrongPassword(input.password)) {
return {
_tag: "Failure",
error: {
_tag: "WeakPassword",
reason: "Must be ≥8 chars, contain uppercase letter and digit"
}
};
}
// 2. Business rule: unique email
if (emailAlreadyTaken(input.email)) {
return {
_tag: "Failure",
error: { _tag: "EmailAlreadyExists", email: input.email }
};
}
// 3. Success case — core decides the shape of the domain entity
const newUser: User = {
id: crypto.randomUUID(), // ← fine in core (not real side-effect)
email: input.email.trim().toLowerCase(),
name: input.name.trim(),
isActive: true,
};
return { _tag: "Success", user: newUser };
};
// =============================================
// FILE: src/adapters/user-repository.port.ts
// =============================================
export interface UserRepository {
emailExists(email: string): Promise<boolean>;
saveUser(user: User, plainPassword: string): Promise<void>;
}
// =============================================
// FILE: src/adapters/user-repository.fake.ts
// (you would swap this with real postgres / mongo impl)
// =============================================
export const fakeUserRepository: UserRepository = {
async emailExists(email: string) {
// Fake "database"
const taken = new Set(["alice@example.com", "bob@work.com"]);
return taken.has(email.toLowerCase());
},
async saveUser(user: User, plainPassword: string) {
console.log(`[DB] Would save user: ${user.email}`);
console.log(`[DB] Password hash would be stored here (plain = ${plainPassword.slice(0,3)}...)`);
// In real life: await db.insert("users", { ...user, passwordHash: await hash(plainPassword) })
},
};
// =============================================
// Imperative shell — all side-effects live here
// FILE: src/shell/register-user.handler.ts (inner shell)
// =============================================
import { createUserLogic, type CreateUserInput, type CreateUserResult } from "../core/user-service";
import type { UserRepository } from "../adapters/user-repository.port";
export async function registerUser(
input: CreateUserInput,
repo: UserRepository
): Promise<CreateUserResult> {
try {
// 1. Check existence (side-effect)
const emailTaken = await repo.emailExists(input.email);
// 2. Run pure business logic
const result = createUserLogic(input, () => emailTaken);
if (result._tag === "Failure") {
return result;
}
// 3. Persist (side-effect)
await repo.saveUser(result.user, input.password);
// 4. Optional: logging, metrics, emails, events...
console.log(`User registered successfully: ${result.user.email}`);
return result;
} catch (err) {
console.error("Unexpected error during registration:", err);
return {
_tag: "Failure",
error: {
_tag: "UnexpectedError",
message: err instanceof Error ? err.message : "Unknown error"
}
};
}
}
// =============================================
// FILE: src/main.ts (outer shell)
// =============================================
import { registerUser } from "./shell/register-user.handler";
import { fakeUserRepository } from "./adapters/user-repository.fake";
async function main() {
const input = {
email: "charlie@example.com",
name: "Charlie Brown",
password: "Passw0rd123",
};
const result = await registerUser(input, fakeUserRepository);
if (result._tag === "Success") {
console.log("Success!", result.user);
} else {
console.log("Failed:", result.error);
}
}
main().catch(console.error);- Key points of this styleCore = 100% pure, synchronous, no Promise, no Effect, no Task, no monads
- Core returns plain discriminated unions (Success / Failure)
- All I/O (database, hashing, logging, env, http, …) is pushed to the shell
- Shell is imperative / async / can throw / can log
- Easy to unit-test the core without mocks
- Still follows functional core / imperative shell separation
Would you prefer using a real Either type (from fp-ts, effect, neverthrow, …), or do you like the simple discriminated union style shown above?
Here's a clean version that follows your request very literally:
- Functional Core → completely pure functions, no
Effect, no promises, no async, just plain values + error-as-values (using discriminated union) - Imperative Shell → uses
Effectfromeffectto handle all side-effects, sequencing, error handling, dependency injection, etc.
// =============================================
// src/core/types.ts
// =============================================
export interface User {
readonly id: string
readonly email: string
readonly name: string
readonly isActive: boolean
}
export interface CreateUserInput {
readonly email: string
readonly name: string
readonly password: string // plain text – hashing belongs in shell
}
export type CreateUserError =
| { _tag: 'EmailAlreadyExists'; email: string }
| { _tag: 'InvalidEmailFormat'; email: string }
| { _tag: 'WeakPassword'; reason: string }
| { _tag: 'Unexpected'; message: string }
export type CreateUserResult =
| { _tag: 'Success'; user: User }
| { _tag: 'Failure'; error: CreateUserError }
// =============================================
// src/core/user-core.ts
// Pure – no Effect, no async, no side-effects
// =============================================
const isValidEmail = (email: string): boolean =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
const isStrongEnoughPassword = (pw: string): boolean =>
pw.length >= 8 && /[A-Z]/.test(pw) && /\d/.test(pw)
export const createUserPure = (
input: CreateUserInput,
emailIsAlreadyTaken: (email: string) => boolean
): CreateUserResult => {
if (!isValidEmail(input.email)) {
return {
_tag: 'Failure',
error: { _tag: 'InvalidEmailFormat', email: input.email }
}
}
if (!isStrongEnoughPassword(input.password)) {
return {
_tag: 'Failure',
error: {
_tag: 'WeakPassword',
reason: '≥ 8 chars, at least one uppercase letter and one digit'
}
}
}
if (emailIsAlreadyTaken(input.email)) {
return {
_tag: 'Failure',
error: { _tag: 'EmailAlreadyExists', email: input.email }
}
}
return {
_tag: 'Success',
user: {
id: crypto.randomUUID(),
email: input.email.trim().toLowerCase(),
name: input.name.trim(),
isActive: true
}
}
}
// =============================================
// src/ports/user-repository.port.ts
// =============================================
import { Effect } from 'effect'
import type { User } from '../core/types'
export class UserRepository extends Effect.Tag('UserRepository')<
UserRepository,
{
readonly emailExists: (email: string) => Effect.Effect<boolean>
readonly save: (user: User, plainPassword: string) => Effect.Effect<void>
}
>() {}
// =============================================
// src/adapters/user-repository.live.ts
// (fake implementation – replace with real DB)
// =============================================
import { Effect, Layer } from 'effect'
import { UserRepository } from '../ports/user-repository.port'
export const UserRepositoryLive = Layer.succeed(
UserRepository,
UserRepository.of({
emailExists: (email) =>
Effect.sync(() => {
const taken = new Set(['alice@example.com', 'bob@work.com'])
return taken.has(email.toLowerCase())
}),
save: (user, plainPassword) =>
Effect.sync(() => {
console.log(`[DB] Saving user ${user.email}`)
console.log(`[DB] Would hash and store password (plain prefix: ${plainPassword.slice(0,3)}...)`)
// In real code: await db.insert(...) or similar
})
})
)
// =============================================
// src/shell/register-user.handler.ts
// IMPERATIVE SHELL – uses Effect for composition & side-effects
// =============================================
import { Effect, pipe } from 'effect'
import { createUserPure, type CreateUserInput, type CreateUserResult } from '../core/user-core'
import { UserRepository } from '../ports/user-repository.port'
export const registerUserProgram = (
input: CreateUserInput
): Effect.Effect<CreateUserResult, never> =>
pipe(
UserRepository.emailExists(input.email),
Effect.map(emailTaken => createUserPure(input, () => emailTaken)),
Effect.flatMap(result =>
result._tag === 'Failure'
? Effect.succeed(result)
: pipe(
UserRepository.save(result.user, input.password),
Effect.as(result),
Effect.tap(() =>
Effect.sync(() => console.log(`Registered: ${result.user.email}`))
)
)
)
)
// =============================================
// src/main.ts
// =============================================
import { Effect, Layer } from 'effect'
import { registerUserProgram } from './shell/register-user.handler'
import { UserRepositoryLive } from './adapters/user-repository.live'
const program = registerUserProgram({
email: 'charlie@acme.org',
name: 'Charlie Example',
password: 'SunnyH1lls2025'
})
const MainLive = Layer.mergeAll(UserRepositoryLive)
Effect.runPromiseExit(program.pipe(Effect.provide(MainLive)))
.then(exit => {
if (exit._tag === 'Success') {
console.log('Result:', exit.value)
} else {
console.error('Failed:', exit.cause)
}
})| Part | Uses Effect? |
Pure / synchronous? | Side-effects? | Test strategy |
|---|---|---|---|---|
user-core.ts |
No | Yes | No | Just call with mock predicate |
user-repository.port |
Yes (interface) | — | — | — |
user-repository.live |
Yes | No | Yes | Replace with test layer |
register-user.handler |
Yes | No | Yes | Provide test layers / mocks |
main.ts |
Yes | No | Yes | Usually not unit-tested |
This style keeps the business/domain logic extremely simple and pure while still using Effect where side-effects and dependency wiring actually happen.
Let me know if you'd like:
- real error channel instead of
CreateUserResult - password hashing added in the shell
- dependency on current time / uuid generator passed from shell
- http endpoint wrapper example etc.
FC/IS architecture in TypeScript, by using Effect.ts with generators
Here we also exhaustively pattern-match errors, so that the shell is forced to handle all errors that may arise from the functional core (the business logic). We could have avoided this, and just forwarded errors (for simple cases), but see the end of this post for the reasoning why we didn't avoid it. It is also nice that Network concerns like HTTP status codes are handled in the appropriate place (the Imperative Shell), and doesn't sneak into the pure business logic (Functional Core).
Here is the full, self-contained example using
Effect.matchwithMatch.valueTagsto enforce exhaustive, typed handling of every possibleCreateUserErrorcase at the presentation / boundary layer.Output examples (depending on input)
Successful case
Email already exists
Invalid email
Weak password
Why this is powerful
CreateUserErrorvariant inMatch.valueTags.RateLimitExceeded), the code won't compile until you handle it.Effecterror channel becomesneveraftermatch→ clean Promise-like API at the edges.This pattern is very common in Effect-based APIs (GraphQL resolvers, HTTP handlers, CLI commands, etc.).
Let me know if you'd like to see this wrapped in a tiny HTTP endpoint (e.g. with Elysia or Hono)!
Here is a simple ASCII diagram showing how the pieces from the example relate to each other in the Functional Core / Imperative Shell style we were using.
Quick explanation of the flow / dependencies
main.ts
→ wires everything (layers, real/fake implementations)
→ calls the presentation function
presentation (
getRegistrationResult)→ takes the
Effectprogram from shell→ turns domain results/errors into API/CLI/UI-friendly shape
→ does exhaustive matching on business errors (
Match.valueTags)imperative shell (
registerUserProgram)→ orchestrates the use case
→ calls pure core logic
→ performs side-effects via ports/repositories
→ adds logging, metrics, etc. if needed
functional core (
createUserLogic)→ pure business/validation/decision logic
→ returns
Effect<User, CreateUserError>(typed failure channel)→ depends only on abstract capabilities (passed as functions or via tags)
ports / services (
UserRepositorytag)→ abstract interface/capability (what the core needs)
→ defined with
Context.Taglive adapters (
UserRepositoryLive)→ concrete implementations (fake, postgres, mongo, …)
→ provided via
Layerat runtimeArrows show dependency / call direction:
→ = calls / uses
◄───────────► = depends on abstract port (inversion of control)
Let me know if you'd like a version zoomed in on one part or with different emphasis.