Skip to content

Instantly share code, notes, and snippets.

@redbar0n
Last active May 6, 2026 12:55
Show Gist options
  • Select an option

  • Save redbar0n/af5e339ea8b2563738455dcdf6042ed5 to your computer and use it in GitHub Desktop.

Select an option

Save redbar0n/af5e339ea8b2563738455dcdf6042ed5 to your computer and use it in GitHub Desktop.
Functional Core, Imperative Shell (FCIS) in a TypeScript back-end

--- 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).

Just TS with simple discriminated union style:

// =============================================
// 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?

With Effect.ts

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 Effect from effect to 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)
    }
  })

Summary – separation rules applied here

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.
@redbar0n
Copy link
Copy Markdown
Author

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.match with Match.valueTags to enforce exhaustive, typed handling of every possible CreateUserError case at the presentation / boundary layer.

// =============================================
// 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 };


// =============================================
// src/core/user-core.ts
// =============================================
import { Effect } from "effect";

const isValidEmail = (email: string): boolean =>
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

const isStrongPassword = (pw: string): boolean =>
  pw.length >= 8 && /[A-Z]/.test(pw) && /\d/.test(pw);

export const createUserLogic = (
  input: CreateUserInput,
  checkEmailTaken: (email: string) => Effect.Effect<boolean>
): Effect.Effect<User, CreateUserError> =>
  Effect.gen(function* (_) {
    if (!isValidEmail(input.email)) {
      return yield* _(
        Effect.fail<CreateUserError>({
          _tag: "InvalidEmailFormat",
          email: input.email,
        })
      );
    }

    if (!isStrongPassword(input.password)) {
      return yield* _(
        Effect.fail<CreateUserError>({
          _tag: "WeakPassword",
          reason: "Must be ≥8 chars, contain at least one uppercase letter and one digit",
        })
      );
    }

    const taken = yield* _(checkEmailTaken(input.email));
    if (taken) {
      return yield* _(
        Effect.fail<CreateUserError>({
          _tag: "EmailAlreadyExists",
          email: input.email,
        })
      );
    }

    return {
      id: crypto.randomUUID(),
      email: input.email.trim().toLowerCase(),
      name: input.name.trim(),
      isActive: true,
    } satisfies User;
  });


// =============================================
// src/ports/user-repository.port.ts
// =============================================
import { Effect, Context } from "effect";
import type { User } from "../core/types";

export class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    readonly emailExists: (email: string) => Effect.Effect<boolean>;
    readonly save: (user: User, plainPassword: string) => Effect.Effect<void>;
  }
>() {}

// Fake / demo implementation
export const UserRepositoryLive = 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] (password prefix: ${plainPassword.slice(0, 3)}...)`);
    }),
});


// =============================================
// src/shell/register-user.handler.ts
// =============================================
import { Effect, pipe } from "effect";
import { createUserLogic, type CreateUserInput } from "../core/user-core";
import { UserRepository } from "../ports/user-repository.port";

export const registerUserProgram = (input: CreateUserInput) =>
  Effect.gen(function* (_) {
    const user = yield* _(
      createUserLogic(input, (email) => UserRepository.emailExists(email))
    );

    yield* _(UserRepository.save(user, input.password));

    yield* _(
      Effect.sync(() => console.log(`User registered successfully: ${user.email}`))
    );

    return user;
  });


// =============================================
// src/presentation/register-user-response.ts
//   ← This is where we enforce exhaustive handling of every CreateUserError case
// =============================================
import { Effect, Match } from "effect";
import { registerUserProgram } from "../shell/register-user.handler";
import type { CreateUserInput, CreateUserError, User } from "../core/types";

// This function transforms the Effect program into a user-friendly result
// (e.g. for an API response, CLI output, etc.)
export const getRegistrationResult = (input: CreateUserInput) =>
  pipe(
    registerUserProgram(input),

    Effect.match({
      // ────────────────────────────────────────────────
      // Exhaustive handling of all known business errors
      // Compiler will error if you forget to handle any _tag
      // ────────────────────────────────────────────────
      onFailure: Match.valueTags<CreateUserError>()({
        EmailAlreadyExists: (e) => ({
          success: false as const,
          status: 409,
          message: `Email ${e.email} is already registered.`,
        }),

        InvalidEmailFormat: (e) => ({
          success: false as const,
          status: 400,
          message: `Invalid email format: ${e.email}`,
        }),

        WeakPassword: (e) => ({
          success: false as const,
          status: 422,
          message: `Password requirements not met: ${e.reason}`,
        }),
      }),

      // ────────────────────────────────────────────────
      // Success case
      // ────────────────────────────────────────────────
      onSuccess: (user: User) => ({
        success: true as const,
        status: 201,
        user: {
          id: user.id,
          email: user.email,
          name: user.name,
        },
      }),
    })
  );


// =============================================
// src/main.ts   (entry point example)
// =============================================
import { Effect, Layer, Exit } from "effect";
import { UserRepositoryLive } from "../ports/user-repository.port";
import { getRegistrationResult } from "../presentation/register-user-response";

const input: CreateUserInput = {
  email: "charlie@example.com",
  name: "Charlie Example",
  password: "SunnyH1lls2025",
};

const MainLayer = Layer.succeed(UserRepository, UserRepositoryLive);

Effect.runPromiseExit(
  getRegistrationResult(input).pipe(Effect.provide(MainLayer))
).then((exit: Exit.Exit<any, never>) => {
  // Because we used Effect.match, the error channel is now `never`
  // → only success values reach here (or defects if something really breaks)
  if (Exit.isSuccess(exit)) {
    console.log("Final result:");
    console.dir(exit.value, { depth: null });
  } else {
    console.error("Unexpected failure (defect):", exit.cause);
  }
});

Output examples (depending on input)

Successful case

{
  success: true,
  status: 201,
  user: {
    id: "550e8400-e29b-41d4-a716-446655440000",
    email: "charlie@example.com",
    name: "Charlie Example"
  }
}

Email already exists

{
  success: false,
  status: 409,
  message: "Email charlie@example.com is already registered."
}

Invalid email

{
  success: false,
  status: 400,
  message: "Invalid email format: charlie@invalid"
}

Weak password

{
  success: false,
  status: 422,
  message: "Password requirements not met: Must be ≥8 chars, contain at least one uppercase letter and one digit"
}

Why this is powerful

  • The compiler forces you to handle every possible CreateUserError variant in Match.valueTags.
  • If someone adds a new error case later (e.g. RateLimitExceeded), the code won't compile until you handle it.
  • The presentation layer produces consistent, user-friendly responses without leaking internal error shapes.
  • The Effect error channel becomes never after match → 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.

                ┌───────────────────────┐
                │        main.ts        │
                │   (composition root)  │
                │                       │
                │  Effect.provide(…)    │
                │  Layer.mergeAll(…)    │
                └───────────┬───────────┘
                            │
                            │ provides layers / dependencies
                            ▼
                ┌───────────────────────┐
                │     presentation      │
                │ (getRegistrationResult)│
                │                       │
                │  Effect.match {       │
                │    onFailure: Match.  │ ← exhaustive handling of CreateUserError
                │    onSuccess: …       │
                │  }                    │
                └───────────┬───────────┘
                            │ returns Effect<API-friendly result, never>
                            ▼
                ┌───────────────────────┐
                │   imperative shell    │
                │ (registerUserProgram) │
                │                       │
                │  Effect.gen {         │
                │    yield* core        │
                │    yield* repository  │
                │    logging / side fx  │
                │  }                    │
                └───────────┬───────────┘
                            │ calls pure core + uses ports
                            ▼
          ┌─────────────────┼─────────────────┐
          │                                   │
┌─────────────────────┐             ┌─────────────────────┐
│   functional core   │             │  ports / services   │
│  (user-core.ts)     │◄───────────►│ (UserRepository     │
│                     │   depends    │  tag / interface)   │
│  Effect.gen {       │   on port    │                     │
│    validation       │             │                     │
│    business rules   │             │                     │
│    pure logic       │             │                     │
│  }                  │             │                     │
└─────────────────────┘             └──────────┬──────────┘
                                               │ implemented by
                                               ▼
                                     ┌─────────────────────┐
                                     │  live adapters      │
                                     │ (UserRepositoryLive)│
                                     │   • fake/in-memory  │
                                     │   • real postgres   │
                                     │   • etc…            │
                                     └─────────────────────┘

Quick explanation of the flow / dependencies

  • main.ts
    → wires everything (layers, real/fake implementations)
    → calls the presentation function

  • presentation (getRegistrationResult)
    → takes the Effect program 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 (UserRepository tag)
    → abstract interface/capability (what the core needs)
    → defined with Context.Tag

  • live adapters (UserRepositoryLive)
    → concrete implementations (fake, postgres, mongo, …)
    → provided via Layer at runtime

Arrows 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.

@redbar0n
Copy link
Copy Markdown
Author

redbar0n commented Mar 17, 2026

1. crypto.randomUUID() is a side effect / impurity

In strict functional programming terms, any function that

  • depends on hidden global state,
  • produces different output for the same input,
  • or interacts with the outside world

… is impure.

crypto.randomUUID() (in browsers and modern Node.js) uses cryptographically secure randomness from the OS/environment → same arguments → different results → not pure.

So in a maximally pure core it is problematic.


Why use a "Functional Core, Imperative Shell" architecture on the backend?

"Boundaries" talk by Gary Bernhardt:

"Imperative shell" that wraps and uses your "functional core".. The result of this is that the shell has fewer paths, but more dependencies. The core contains no dependencies, but encapsulates the different logic paths. So we’re encapsulating dependencies on one side, and business logic on the other side. Or put another way, the way to figure out the separation is by doing as much as you can without mutation, and then encapsulating the mutation separately. Functional core — Many fast unit tests. Imperative shell — Few integration tests.

https://www.youtube.com/watch?v=yTkzNHF6rMs
https://www.destroyallsoftware.com/talks/boundaries
https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell


Why use Effect.ts and not just regular async/await ?

Effect.ts lets us to handle I/O masterfully in TypeScript by allowing us to:

  • avoid Exceptions entirely, and use Result union instead. No more timid or verbose try/catch coding.
  • type safe error handling: let the type inference ensure we exhaustively pattern-match on all possible Errors.
  • use simple built-in retry policies in case of I/O errors.
  • have proper concurrency support: fibers for true parallelism, cancellation propagation, and backpressure in streams/queues.
  • batch requests, if needed.
  • abort/cancel requests, far better than AbortSignal in JS.
  • have full observability / logging / tracing.
  • parse/validate input data according to a schema (@effect/schema is akin to Zod), and infer static types from there.
  • handle sync / async uniformly (alleviate function coloring problem with async/await).

2. Updated version – shell still uses generators, core is pure

// =============================================
// CORE
// 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;
}

export type CreateUserError =
  | { readonly _tag: "EmailAlreadyExists"; readonly email: string }
  | { readonly _tag: "InvalidEmailFormat"; readonly email: string }
  | { readonly _tag: "WeakPassword";       readonly reason: string };

export type CreateUserResult =
  | { readonly _tag: "Success"; readonly userWithoutId: Omit<User, "id"> }
  | { readonly _tag: "Failure"; readonly error: CreateUserError };


// =============================================
// CORE
// src/core/user-core.ts
// 100% pure – no randomness, no side effects, no arguments that could do I/O
// =============================================
export const createUserLogic = (
  input: CreateUserInput,
  isEmailTaken: boolean
): CreateUserResult => {
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
    return {
      _tag: "Failure",
      error: { _tag: "InvalidEmailFormat", email: input.email },
    };
  }

  if (input.password.length < 8 || !/[A-Z]/.test(input.password) || !/\d/.test(input.password)) {
    return {
      _tag: "Failure",
      error: {
        _tag: "WeakPassword",
        reason: "≥8 chars, at least one uppercase letter and one digit",
      },
    };
  }

  if (isEmailTaken) {
    return {
      _tag: "Failure",
      error: { _tag: "EmailAlreadyExists", email: input.email },
    };
  }

  return {
    _tag: "Success",
    userWithoutId: {
      email: input.email.trim().toLowerCase(),
      name: input.name.trim(),
      isActive: true,
    },
  };
};



// =============================================
// SHELL (port to DB adapter/repository)
// "Ports & Adapters" architecture aka. Hexagonal aka. FC/IS.
// src/ports-and-adapters/user-repository.ts
// =============================================
import { Effect, Context } from "effect";
import type { User } from "../core/types";

// "Port" (interface)
export class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    readonly emailExists: (email: string) => Effect.Effect<boolean>;
    readonly save: (user: User, plainPassword: string) => Effect.Effect<void>;
  }
>() {}

// "Adapter" (implementation of the port)
// Mock / fake implementation of a DB, for testing and demo purpose in this example. You would swap this with a real PostgreSQL or other DB implementation.
export const UserRepositoryMock = 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] (password prefix: ${plainPassword.slice(0, 3)}...)`);
    }),
});


// =============================================
// SHELL
// src/shell/register-user.handler.ts
// Uses Effect.gen, adds id + saves only on success
// =============================================
import { Effect, pipe } from "effect";
import { createUserLogic, type CreateUserInput, type CreateUserResult } from "../core/user-core";
import { UserRepository } from "../ports-and-adapters/user-repository";

export const registerUserProgram = (input: CreateUserInput) =>
  Effect.gen(function* (_) {
    // 1. Get data needed for decision (side effect)
    const isTaken = yield* _(UserRepository.emailExists(input.email));

    // 2. Run pure core logic
    const result: CreateUserResult = createUserLogic(input, isTaken);

    if (result._tag === "Failure") {
      // Early return – business error flows out
      return yield* _(Effect.fail(result.error));
    }

    // 3. Only on success: generate id + save (side effects)
    const user = {
      id: crypto.randomUUID(),           // ← impurity pushed to shell
      ...result.userWithoutId,
    };

    yield* _(UserRepository.save(user, input.password));

    yield* _(
      Effect.sync(() => console.log(`Registered user: ${user.email} (ID: ${user.id})`))
    );

    return user;
  });


// =============================================
// OUTER SHELL
// Example presentation / boundary layer
// (could be http handler, cli command, etc.)
// =============================================
import { Effect, Match } from "effect";

export const handleRegistration = (input: CreateUserInput) =>
  pipe(
    registerUserProgram(input),

    Effect.match({
      onFailure: Match.valueTags()({
        EmailAlreadyExists: e => ({ status: 409, success: false, message: `Email ${e.email} already taken` }),
        InvalidEmailFormat: e => ({ status: 400, success: false, message: `Bad email format: ${e.email}` }),
        WeakPassword:       e => ({ status: 422, success: false, message: e.reason }),
      }),
      onSuccess: user => ({ status: 201, success: true, user }),
    })
  );

Run it (for demo purposes — in real app you'd use Effect.runPromise in server/CLI):

// =============================================
// src/main.ts
// Entry point / composition root
// =============================================
import { Effect, Layer, Exit } from "effect";
import { UserRepository, UserRepositoryMock } from "./ports-and-adapters/user-repository";
import { handleRegistration } from "./presentation/register-user-response"; // or wherever you put it
import type { CreateUserInput } from "./core/types";

// Example input (could come from CLI args, HTTP body, etc.)
const exampleInput: CreateUserInput = {
  email: "charlie@example.com",
  name: "Charlie Example",
  password: "SunnyH1lls2025",
};

// Provide real/mock dependencies, the interface/port first and then the implementation/adapter.
const MainLayer = Layer.succeed(UserRepository, UserRepositoryMock);

// The full program (presentation layer wraps shell + error handling)
const program = handleRegistration(exampleInput).pipe(
  Effect.provide(MainLayer)
);

// Run it (for demo purposes — in real app you'd use Effect.runPromise in server/CLI)
Effect.runPromiseExit(program)
  .then((exit: Exit.Exit<any, any>) => {
    if (Exit.isSuccess(exit)) {
      console.log("API Response (success):");
      console.dir(exit.value, { depth: null });
      // Example output:
      // { status: 201, success: true, user: { id: '...', email: '...', name: '...' } }
    } else {
      console.log("API Response (failure) or defect:");
      console.dir(exit.cause, { depth: null });
      // In real app: map cause to proper HTTP status + body
    }
  })
  .catch((err) => {
    console.error("Unexpected runtime error:", err);
  });

// Alternative: if you prefer Promise style for top-level
// Effect.runPromise(program)
//   .then(console.log)
//   .catch(console.error);

Or run it in a tiny Hono server:

// Alternative main.ts with Hono (optional)
import { Hono } from "hono";
import { serve } from "@hono/node-server";
import { handleRegistration } from "./presentation/register-user-response";
import { MainLayer } from "./some-layer-file"; // or define here

const app = new Hono();

app.post("/register", async (c) => {
  const input = await c.req.json<CreateUserInput>();
  
  const result = await Effect.runPromise(
    handleRegistration(input).pipe(Effect.provide(MainLayer))
  );

  return c.json(result, result.success ? 201 : result.status);
});

serve(app, { port: 3000 });
console.log("Server running on http://localhost:3000");

Summary – what changed / why it's cleaner now

  • Core → completely pure, deterministic, no arguments that could hide effects
  • Shell → still uses Effect.gen, orchestrates side-effects (email check + uuid + save + log)
  • Id generation moved to shell → impurity is explicit and contained
  • Business errors are still short-circuited naturally via Effect.fail
  • Presentation layer can still pattern-match exhaustively

This is currently one of the most common pragmatic styles when people want a very strict pure core while still using Effect in the imperative shell.


Here is a clean ASCII/Markdown diagram showing the latest architecture from the code examples (pure functional core without any dependencies or effects, shell handling side-effects + id generation, presentation doing exhaustive matching, ports/adapters via Effect layers, main as composition root).

                               ┌───────────────────────┐
                               │        main.ts        │
                               │  (composition root)   │
                               │                       │
                               │  • Effect.provide     │
                               │  • Layer.succeed(...) │
                               └───────────┬───────────┘
                                           │
                                           │ provides real/mock layers
                                           ▼
                               ┌───────────────────────┐
                               │   presentation        │
                               │ (getRegistrationResult│
                               │  or handleRegistration│
                               │                       │
                               │  • Effect.match       │
                               │  • Match.valueTags    │  ← exhaustive CreateUserError handling
                               │  • returns API/CLI    │
                               │    friendly result    │
                               └───────────┬───────────┘
                                           │
                                           │ calls shell program
                                           ▼
                               ┌───────────────────────┐
                               │   imperative shell    │
                               │ (registerUserProgram) │
                               │                       │
                               │  • Effect.gen {       │
                               │    • yield* repo      │
                               │    • pure core call   │
                               │    • crypto.randomUUID│  ← impurity here
                               │    • yield* save      │
                               │    • logging / tap    │
                               │  }                    │
                               └───────────┬───────────┘
                                           │
                         calls pure core   │  uses ports (via Effect.Tag)
                      ┌────────────────────┼────────────────────┐
                      ▼                    │                    ▼
        ┌─────────────────────────┐        │        ┌─────────────────────────┐
        │   functional core       │        │        │     ports / services    │
        │  (createUserLogic)      │        │        │  (UserRepository Tag)   │
        │                         │        │        │                         │
        │  • pure functions       │◄───────┼───────►│  • abstract capability  │
        │  • just data            │        │        │                │
        │  • all domain logic     │        │        │                │
        │  • takes boolean only   │        │        │  • emailExists()        │
        │  • returns Result union │        │        │  • save()               │
        │  • no Effect.ts         │        │        │                         │
        │  • no id generation     │        │        └───────────┬─────────────┘
        └─────────────────────────┘        │                    │ implemented by
                                           │                    ▼
                                           │        ┌─────────────────────────┐
                                           │        │   live adapters         │
                                           │        │  (UserRepositoryMock)   │
                                           │        │                         │
                                           │        │  • fake/mock Set-based       │
                                           │        │  • real DB (postgres,   │
                                           │        │    mongo, etc.)         │
                                           │        └─────────────────────────┘
                                           │
                                           └─ data flows back up as Effect<User, CreateUserError>

Legend / Flow summary

  • → / ↓ = calls / uses / provides
  • ◄──────► = core depends on plain values passed from shell (boolean isEmailTaken)
  • Impurity boundary = everything below the core (shell + ports + adapters + main) handles side-effects
  • Core stays pure = no Effect, no randomness, no I/O, no function dependencies
  • Presentation = last place where business errors are pattern-matched exhaustively
  • Shell = orchestrates Effect.gen, adds id, performs persistence/logging

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment