Skip to content
GitHub

Universal Concepts

This guide explores the fundamental concepts and patterns that make Effectify’s universal packages work seamlessly across React, SolidJS, and Node.js applications.

Effect is a powerful TypeScript library for building robust, composable applications. It provides:

  • Type-safe error handling: Errors are part of the type signature
  • Composability: Operations can be combined and transformed
  • Resource management: Automatic cleanup of resources
  • Testability: Easy mocking and testing
  • Concurrency: Built-in support for async operations
import { Effect } from "effect"

// Effect<Success, Error, Requirements>
const fetchUser = (id: string): Effect<User, UserNotFoundError, UserRepository> =>
  Effect.gen(function*() {
    const repo = yield* UserRepository
    const user = yield* repo.findById(id)

    if (!user) {
      yield* Effect.fail(new UserNotFoundError({ userId: id }))
    }

    return user
  })

Effects can be composed in various ways:

// Sequential composition
const processUser = (id: string) =>
  Effect.gen(function*() {
    const user = yield* fetchUser(id)
    const validated = yield* validateUser(user)
    const updated = yield* updateUser(validated)

    return updated
  })

// Parallel composition
const fetchUserData = (id: string) =>
  Effect.all([
    fetchUser(id),
    fetchUserPreferences(id),
    fetchUserActivity(id),
  ])

// Conditional composition
const fetchUserWithFallback = (id: string) =>
  fetchUser(id).pipe(
    Effect.catchTag("UserNotFoundError", () => createDefaultUser(id)),
  )

Universal packages distinguish between entities (objects with identity) and value objects (immutable data):

// Entity - has identity
interface User {
  readonly id: UserId // Identity
  readonly email: Email
  readonly profile: UserProfile
  readonly createdAt: Date
  readonly updatedAt: Date
}

// Value Object - immutable data
interface UserProfile {
  readonly name: UserName
  readonly bio: string
  readonly avatar?: string
}

// Value object constructor
const UserProfile = {
  create: (name: string, bio: string, avatar?: string) =>
    Effect.gen(function*() {
      const validName = yield* UserName.make(name)
      const validBio = yield* validateBio(bio)

      return {
        name: validName,
        bio: validBio,
        avatar,
      }
    }),
}

Aggregates are clusters of domain objects that are treated as a single unit:

// Chat Room Aggregate
interface ChatRoom {
  readonly id: RoomId
  readonly name: RoomName
  readonly participants: Set<UserId>
  readonly messages: Message[]
  readonly settings: RoomSettings
  readonly createdAt: Date
}

const ChatRoomAggregate = {
  // Factory method
  create: (name: string, creatorId: UserId) =>
    Effect.gen(function*() {
      const roomId = yield* RoomId.generate()
      const roomName = yield* RoomName.make(name)

      return {
        id: roomId,
        name: roomName,
        participants: new Set([creatorId]),
        messages: [],
        settings: defaultRoomSettings,
        createdAt: new Date(),
      }
    }),

  // Business methods
  addParticipant: (room: ChatRoom, userId: UserId) =>
    Effect.gen(function*() {
      if (room.participants.has(userId)) {
        yield* Effect.fail(
          new BusinessRuleViolationError({
            rule: "unique-participant",
            message: "User is already a participant",
          }),
        )
      }

      if (room.participants.size >= room.settings.maxParticipants) {
        yield* Effect.fail(
          new BusinessRuleViolationError({
            rule: "max-participants",
            message: "Room has reached maximum participants",
          }),
        )
      }

      return {
        ...room,
        participants: new Set([...room.participants, userId]),
      }
    }),

  sendMessage: (room: ChatRoom, message: Message) =>
    Effect.gen(function*() {
      if (!room.participants.has(message.userId)) {
        yield* Effect.fail(
          new BusinessRuleViolationError({
            rule: "participant-only-messaging",
            message: "Only participants can send messages",
          }),
        )
      }

      const validatedMessage = yield* validateMessage(message)

      return {
        ...room,
        messages: [...room.messages, validatedMessage],
      }
    }),
}

Domain services contain business logic that doesn’t naturally fit in entities:

const ChatDomainService = {
  // Complex business logic
  calculateUserEngagement: (userId: UserId, timeframe: TimeFrame) =>
    Effect.gen(function*() {
      const messages = yield* MessageRepository.findByUserAndTimeframe(userId, timeframe)
      const reactions = yield* ReactionRepository.findByUserAndTimeframe(userId, timeframe)
      const sessions = yield* SessionRepository.findByUserAndTimeframe(userId, timeframe)

      const messageScore = messages.length * 1.0
      const reactionScore = reactions.length * 0.5
      const sessionScore = sessions.reduce((acc, s) => acc + s.duration, 0) / 1000 / 60 // minutes

      return {
        userId,
        timeframe,
        messageScore,
        reactionScore,
        sessionScore,
        totalScore: messageScore + reactionScore + sessionScore,
      }
    }),

  // Cross-aggregate operations
  migrateUserToNewRoom: (userId: UserId, fromRoomId: RoomId, toRoomId: RoomId) =>
    Effect.gen(function*() {
      const fromRoom = yield* ChatRoomRepository.findById(fromRoomId)
      const toRoom = yield* ChatRoomRepository.findById(toRoomId)

      if (!fromRoom || !toRoom) {
        yield* Effect.fail(
          new ResourceNotFoundError({
            resource: "ChatRoom",
            id: !fromRoom ? fromRoomId : toRoomId,
          }),
        )
      }

      const updatedFromRoom = yield* ChatRoomAggregate.removeParticipant(fromRoom, userId)
      const updatedToRoom = yield* ChatRoomAggregate.addParticipant(toRoom, userId)

      yield* ChatRoomRepository.save(updatedFromRoom)
      yield* ChatRoomRepository.save(updatedToRoom)

      yield* EventBus.publish(new UserMigratedEvent(userId, fromRoomId, toRoomId))
    }),
}

Domain events represent things that have happened in the domain:

// Base event interface
interface DomainEvent {
  readonly eventId: string
  readonly aggregateId: string
  readonly eventType: string
  readonly occurredAt: Date
  readonly version: number
}

// Specific domain events
class UserRegisteredEvent implements DomainEvent {
  readonly eventId = crypto.randomUUID()
  readonly eventType = "UserRegistered"
  readonly occurredAt = new Date()
  readonly version = 1

  constructor(
    readonly aggregateId: string,
    readonly user: User,
  ) {}
}

class MessageSentEvent implements DomainEvent {
  readonly eventId = crypto.randomUUID()
  readonly eventType = "MessageSent"
  readonly occurredAt = new Date()
  readonly version = 1

  constructor(
    readonly aggregateId: string,
    readonly message: Message,
    readonly roomId: RoomId,
  ) {}
}

Event handlers respond to domain events:

const UserEventHandlers = {
  onUserRegistered: (event: UserRegisteredEvent) =>
    Effect.gen(function*() {
      const logger = yield* Logger
      const emailService = yield* EmailService

      yield* logger.info("User registered", { userId: event.user.id })

      // Send welcome email
      yield* emailService.sendWelcomeEmail(event.user.email, event.user.name)

      // Create default preferences
      yield* UserPreferencesRepository.create({
        userId: event.user.id,
        preferences: defaultUserPreferences,
      })
    }),

  onMessageSent: (event: MessageSentEvent) =>
    Effect.gen(function*() {
      const notificationService = yield* NotificationService
      const room = yield* ChatRoomRepository.findById(event.roomId)

      if (!room) return

      // Notify other participants
      const otherParticipants = Array.from(room.participants)
        .filter((id) => id !== event.message.userId)

      yield* Effect.forEach(otherParticipants, (participantId) =>
        notificationService.sendMessageNotification(
          participantId,
          event.message,
          room.name,
        ))
    }),
}

The event bus coordinates event publishing and handling:

export interface EventBus {
  readonly publish: <T extends DomainEvent>(event: T) => Effect<void>
  readonly subscribe: <T extends DomainEvent>(
    eventType: string,
    handler: (event: T) => Effect<void>,
  ) => Effect<void>
}

// In-memory implementation
const InMemoryEventBus = Layer.succeed(EventBus, {
  publish: <T extends DomainEvent>(event: T) =>
    Effect.gen(function*() {
      const handlers = eventHandlers.get(event.eventType) || []

      yield* Effect.forEach(handlers, (handler) =>
        handler(event).pipe(
          Effect.catchAll((error) =>
            Effect.gen(function*() {
              const logger = yield* Logger
              yield* logger.error("Event handler failed", { event, error })
            })
          ),
        ))
    }),

  subscribe: <T extends DomainEvent>(eventType: string, handler: (event: T) => Effect<void>) =>
    Effect.sync(() => {
      const handlers = eventHandlers.get(eventType) || []
      eventHandlers.set(eventType, [...handlers, handler])
    }),
})

Repositories provide a collection-like interface for accessing aggregates:

export interface Repository<T, ID> {
  readonly findById: (id: ID) => Effect<T | null, RepositoryError>
  readonly save: (entity: T) => Effect<T, RepositoryError>
  readonly delete: (id: ID) => Effect<void, RepositoryError>
}

export interface UserRepository extends Repository<User, UserId> {
  readonly findByEmail: (email: Email) => Effect<User | null, RepositoryError>
  readonly findByIds: (ids: UserId[]) => Effect<User[], RepositoryError>
  readonly search: (criteria: UserSearchCriteria) => Effect<User[], RepositoryError>
}

export interface ChatRoomRepository extends Repository<ChatRoom, RoomId> {
  readonly findByParticipant: (userId: UserId) => Effect<ChatRoom[], RepositoryError>
  readonly findPublicRooms: () => Effect<ChatRoom[], RepositoryError>
}

Different platforms can provide different implementations:

// SQLite implementation
const SqliteUserRepository: UserRepository = {
  findById: (id) =>
    Effect.tryPromise({
      try: () => db.selectFrom("users").where("id", "=", id).executeTakeFirst(),
      catch: (error) => new RepositoryError("Failed to find user", { cause: error }),
    }).pipe(
      Effect.map((row) => row ? mapRowToUser(row) : null),
    ),

  save: (user) =>
    Effect.tryPromise({
      try: () => db.insertInto("users").values(mapUserToRow(user)).execute(),
      catch: (error) => new RepositoryError("Failed to save user", { cause: error }),
    }).pipe(
      Effect.map(() => user),
    ),

  findByEmail: (email) =>
    Effect.tryPromise({
      try: () => db.selectFrom("users").where("email", "=", email).executeTakeFirst(),
      catch: (error) => new RepositoryError("Failed to find user by email", { cause: error }),
    }).pipe(
      Effect.map((row) => row ? mapRowToUser(row) : null),
    ),
}

// PostgreSQL implementation
const PostgresUserRepository: UserRepository = {
  findById: (id) =>
    Effect.tryPromise({
      try: () => pool.query("SELECT * FROM users WHERE id = $1", [id]),
      catch: (error) => new RepositoryError("Failed to find user", { cause: error }),
    }).pipe(
      Effect.map((result) => result.rows[0] ? mapRowToUser(result.rows[0]) : null),
    ),

  save: (user) =>
    Effect.tryPromise({
      try: () =>
        pool.query(
          "INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO UPDATE SET email = $2, name = $3",
          [user.id, user.email, user.name, user.createdAt],
        ),
      catch: (error) => new RepositoryError("Failed to save user", { cause: error }),
    }).pipe(
      Effect.map(() => user),
    ),
}

Specifications encapsulate business rules and can be combined:

// Base specification interface
interface Specification<T> {
  readonly isSatisfiedBy: (candidate: T) => Effect<boolean>
  readonly and: (other: Specification<T>) => Specification<T>
  readonly or: (other: Specification<T>) => Specification<T>
  readonly not: () => Specification<T>
}

// User specifications
const ActiveUserSpecification: Specification<User> = {
  isSatisfiedBy: (user) => Effect.succeed(user.status === "active"),
  and: (other) => AndSpecification(ActiveUserSpecification, other),
  or: (other) => OrSpecification(ActiveUserSpecification, other),
  not: () => NotSpecification(ActiveUserSpecification),
}

const EmailVerifiedSpecification: Specification<User> = {
  isSatisfiedBy: (user) => Effect.succeed(user.emailVerified),
  and: (other) => AndSpecification(EmailVerifiedSpecification, other),
  or: (other) => OrSpecification(EmailVerifiedSpecification, other),
  not: () => NotSpecification(EmailVerifiedSpecification),
}

// Composite specifications
const EligibleForPremiumSpecification = ActiveUserSpecification
  .and(EmailVerifiedSpecification)
  .and(AccountAgeSpecification(30)) // 30 days old

// Usage in domain service
const UserDomainService = {
  upgradeUserToPremium: (user: User) =>
    Effect.gen(function*() {
      const isEligible = yield* EligibleForPremiumSpecification.isSatisfiedBy(user)

      if (!isEligible) {
        yield* Effect.fail(
          new BusinessRuleViolationError({
            rule: "premium-eligibility",
            message: "User is not eligible for premium upgrade",
          }),
        )
      }

      return yield* UserRepository.save({
        ...user,
        plan: "premium",
        upgradedAt: new Date(),
      })
    }),
}

Value objects use smart constructors to ensure validity:

// Email value object
export type Email = string & { readonly _brand: "Email" }

export const Email = {
  make: (value: string): Effect<Email, ValidationError> =>
    Effect.gen(function*() {
      if (!value) {
        yield* Effect.fail(
          new ValidationError({
            field: "email",
            message: "Email is required",
          }),
        )
      }

      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
        yield* Effect.fail(
          new ValidationError({
            field: "email",
            message: "Invalid email format",
          }),
        )
      }

      if (value.length > 254) {
        yield* Effect.fail(
          new ValidationError({
            field: "email",
            message: "Email is too long",
          }),
        )
      }

      return value.toLowerCase() as Email
    }),

  toString: (email: Email): string => email,

  equals: (a: Email, b: Email): boolean => a === b,
}

// Password value object with complex validation
export type Password = string & { readonly _brand: "Password" }

export const Password = {
  make: (value: string): Effect<Password, ValidationError> =>
    Effect.gen(function*() {
      if (!value) {
        yield* Effect.fail(
          new ValidationError({
            field: "password",
            message: "Password is required",
          }),
        )
      }

      if (value.length < 8) {
        yield* Effect.fail(
          new ValidationError({
            field: "password",
            message: "Password must be at least 8 characters",
          }),
        )
      }

      if (!/[A-Z]/.test(value)) {
        yield* Effect.fail(
          new ValidationError({
            field: "password",
            message: "Password must contain at least one uppercase letter",
          }),
        )
      }

      if (!/[a-z]/.test(value)) {
        yield* Effect.fail(
          new ValidationError({
            field: "password",
            message: "Password must contain at least one lowercase letter",
          }),
        )
      }

      if (!/[0-9]/.test(value)) {
        yield* Effect.fail(
          new ValidationError({
            field: "password",
            message: "Password must contain at least one number",
          }),
        )
      }

      return value as Password
    }),

  hash: (password: Password) =>
    Effect.tryPromise({
      try: () => bcrypt.hash(password, 12),
      catch: (error) =>
        new ValidationError({
          field: "password",
          message: "Failed to hash password",
        }),
    }),

  verify: (password: Password, hash: string) =>
    Effect.tryPromise({
      try: () => bcrypt.compare(password, hash),
      catch: (error) =>
        new ValidationError({
          field: "password",
          message: "Failed to verify password",
        }),
    }),
}

Universal packages handle resources safely using Effect’s resource management:

// Database connection resource
const withDatabaseConnection = <A, E>(
  operation: (db: Database) => Effect<A, E>,
) =>
  Effect.acquireUseRelease(
    // Acquire
    Effect.tryPromise({
      try: () => createDatabaseConnection(),
      catch: (error) => new ConnectionError("Failed to connect", { cause: error }),
    }),
    // Use
    operation,
    // Release
    (db) => Effect.sync(() => db.close()),
  )

// File system resource
const withTempFile = <A, E>(
  operation: (filePath: string) => Effect<A, E>,
) =>
  Effect.acquireUseRelease(
    // Acquire
    Effect.tryPromise({
      try: () => fs.mkdtemp(path.join(os.tmpdir(), "effectify-")),
      catch: (error) => new FileSystemError("Failed to create temp file", { cause: error }),
    }),
    // Use
    operation,
    // Release
    (filePath) =>
      Effect.tryPromise({
        try: () => fs.rm(filePath, { recursive: true }),
        catch: () => void 0, // Ignore cleanup errors
      }),
  )

// HTTP client resource
const withHttpClient = <A, E>(
  operation: (client: HttpClient) => Effect<A, E>,
) =>
  Effect.acquireUseRelease(
    Effect.sync(() => new HttpClient()),
    operation,
    (client) => Effect.sync(() => client.destroy()),
  )

These concepts form the foundation of Effectify’s universal packages, enabling consistent, type-safe, and maintainable code across all platforms.