Skip to content

Repository files navigation


Archstone

The TypeScript foundation for serious backend services.

Build on Domain-Driven Design and Clean Architecture — without writing the same boilerplate on every project.

failcraft


npm version license typescript bun


Quick Start · Why Archstone · Usage · Agent Skills · Architecture · Contributing



Why Archstone?

Every backend project in DDD needs the same structural pieces — and most teams rewrite them from scratch each time. Archstone gives you a battle-tested, minimal set of base classes and contracts so you can skip the boilerplate and go straight to modeling your domain.

// ❌ Before — scattered, inconsistent, no error contract
class User { id: string }
function createUser() { throw new Error('not found') }

// ✅ After — structured, predictable, type-safe
class User extends AggregateRoot<UserProps> { ... }
async function createUser(): Promise<Either<NotFoundError, User>> { ... }

Features

Either Functional error handling — use cases never throw
Maybe Nullable value handling without null checks — just, nothing, maybe
Entity / AggregateRoot Identity-based domain objects with built-in event support
ValueObject Equality by value, not reference
UniqueEntityId UUID v7 identity, consistent across your entire domain
WatchedList Track additions and removals in collections without overwriting persistence
UseCase Typed contract for application logic that always returns Either
Repository contracts Define your interface in the domain — implement anywhere in infrastructure
Agent Skills Built-in AI skill so your coding agent knows every DDD convention

Install

bun add archstone
# or
npm install archstone

Minimal dependencies — only failcraft for Either and Maybe. Pure TypeScript.


Usage

Either — stop throwing, start returning

import { Either, left, right } from 'archstone/core'

type FindUserResult = Either<UserNotFoundError, User>

async function findUser(id: string): Promise<FindUserResult> {
  const user = await repo.findById(id)

  if (!user) return left(new UserNotFoundError(id))
  return right(user)
}

// The caller always handles both cases — no surprises
const result = await findUser('123')

if (result.isLeft()) {
  console.error(result.value) // UserNotFoundError
} else {
  console.log(result.value)   // User ✓
}

Maybe — nullable values without null checks

import { Maybe, just, nothing, maybe } from 'archstone/core'

type FindUserResult = Maybe<User>

async function findUser(id: string): Promise<FindUserResult> {
  const user = await repo.findById(id)
  return maybe(user) // wraps null/undefined as nothing(), anything else as just()
}

const result = await findUser('123')

if (result.isNothing()) {
  console.log('not found')
} else {
  console.log(result.value) // User ✓
}

Entity & AggregateRoot — model your domain

import { AggregateRoot } from 'archstone/domain/enterprise'
import { UniqueEntityId, Optional } from 'archstone/core'

interface OrderProps {
  customerId: UniqueEntityId
  total: number
  createdAt: Date
}

class Order extends AggregateRoot<OrderProps> {
  get customerId() { return this.props.customerId }
  get total()      { return this.props.total }

  static create(props: Optional<OrderProps, 'createdAt'>): Order {
    const order = new Order({
      ...props,
      createdAt: props.createdAt ?? new Date(),
    })

    // Raise domain events from inside the aggregate
    order.addDomainEvent(new OrderCreatedEvent(order))
    return order
  }
}

ValueObject — equality that makes sense

import { ValueObject } from 'archstone/core'

interface EmailProps { value: string }

class Email extends ValueObject<EmailProps> {
  get value() { return this.props.value }

  static create(raw: string): Email {
    if (!raw.includes('@')) throw new Error('Invalid email')
    return new Email({ value: raw.toLowerCase() })
  }
}

const a = Email.create('user@example.com')
const b = Email.create('user@example.com')

a.equals(b) // ✅ true — compared by value, not reference

WatchedList — persist only what changed

import { WatchedList } from 'archstone/core'

class TagList extends WatchedList<Tag> {
  compareItems(a: Tag, b: Tag) { return a.id.equals(b.id) }
}

const tags = new TagList([existingTag])
tags.add(newTag)
tags.remove(existingTag)

// Send only the diff to your repository — not the whole list
tags.getNewItems()     // → [newTag]
tags.getRemovedItems() // → [existingTag]

Domain Events — decouple side effects

import type { EventHandler } from 'archstone/core'
import { DomainEvents } from 'archstone/core'

class OnUserCreated implements EventHandler<UserCreatedEvent> {
  constructor(private readonly mailer: Mailer) {
    this.setupSubscriptions()
  }

  setupSubscriptions(): void {
    DomainEvents.register(this.handle.bind(this), UserCreatedEvent.name)
  }

  async handle(event: UserCreatedEvent): Promise<void> {
    await this.mailer.send(event.user.email.value)
  }
}

// Instantiate in infrastructure — handler self-registers via constructor
new OnUserCreated(mailer)

// Dispatch after persistence — events stay inside the aggregate until then
await userRepository.create(user)
DomainEvents.dispatchEventsForAggregate(user.id)

Repository Contracts — keep infrastructure out of your domain

import { Repository, Creatable } from 'archstone/domain/application'

// Define your contract in the application layer
export interface UserRepository extends Repository<User> {
  findByEmail(email: string): Promise<User | null>
}

// Compose only what you need
export interface AuditRepository extends Creatable<AuditLog> {}

// Implement anywhere in infrastructure — domain stays clean

Package Exports

Import Contents
archstone Everything
archstone/core Either, Maybe, left, right, just, nothing, maybe, ValueObject, UniqueEntityId, WatchedList, Optional, DomainEvent, DomainEvents, EventHandler
archstone/domain All domain exports
archstone/domain/enterprise Entity, AggregateRoot
archstone/domain/application UseCase, UseCaseError, repository contracts

All sub-paths share type declarations via a common chunk — mixing imports from multiple sub-paths is fully type-safe with no duplicate declaration conflicts.


Architecture

src/
├── core/                    # Zero domain knowledge — pure language utilities
│   ├── value-object.ts      # Value equality base class
│   ├── unique-entity-id.ts  # UUID v7 identity wrapper
│   ├── watched-list.ts      # Change-tracked collection
│   ├── events/
│   │   ├── domain-event.ts  # Marker interface for all domain events
│   │   ├── domain-events.ts # Central registry and dispatcher (singleton)
│   │   └── event-handler.ts # Generic handler interface EventHandler<T>
│   └── types/
│       └── optional.ts      # Optional<T, K> helper type
│
└── domain/
    ├── enterprise/          # Pure domain model — zero framework dependencies
    │   └── entities/
    │       ├── entity.ts
    │       └── aggregate-root.ts
    │
    └── application/         # Orchestration — use cases & repository contracts
        ├── use-cases/
        │   ├── use-case.ts
        │   └── use-case.error.ts
        └── repositories/
            ├── repository.ts
            ├── findable.ts
            ├── creatable.ts
            ├── saveable.ts
            └── deletable.ts

Technology Stack

Language TypeScript 5+
Runtime / Package Manager Bun (required)
Test Framework bun:test (built-in)
Build Tool bunup
Linter / Formatter Biome via Ultracite
Dependencies failcraftEither and Maybe types

Development Workflow

Requirements: Bun >= 1.0

bun install          # install dev dependencies
bun test             # run tests
bun run build        # compile to dist/
bun x ultracite fix  # lint + format

Branch naming: feat/<name>, fix/<name>, docs/<name>, chore/<name>

Every commit must pass bun test and bun x ultracite fix before pushing.


Coding Standards

  • Error handling: never throw inside use cases — always return left(error) with Either
  • Imports: always import from a layer's index (archstone/core, archstone/domain/enterprise), never from deep paths
  • Layer boundaries: inner layers never import outer ones — core has zero domain knowledge, enterprise never imports application
  • Factories: always provide a static create() factory on entities and value objects — never expose constructors directly
  • Style: no semicolons, 2-space indent, double quotes (enforced by Biome via Ultracite)
  • Commits: Conventional Commitsfeat, fix, chore, docs, refactor, test

Testing

Framework: bun:test. Test files are *.spec.ts co-located with the source they test.

import { test, expect } from "bun:test"

test("example", () => {
  expect(1).toBe(1)
})

Use in-memory repository implementations for use case tests — never couple tests to a real database. Isolate domain event state between tests:

import { DomainEvents } from "archstone/core"
import { beforeEach } from "bun:test"

beforeEach(() => {
  DomainEvents.clearHandlers()
  DomainEvents.clearMarkedAggregates()
})

Agent Skills — new in v1.1.0

Archstone ships with a built-in skill for AI coding agents. Once installed, your agent understands every DDD convention, layer boundary, and usage pattern — without you ever having to explain them.

The skill covers:

  • Entities, aggregates, and value objects
  • Use cases with Either error handling
  • Repository contracts and in-memory implementations
  • Domain events — raising, dispatching, and handling
  • Testing patterns with bun:test and in-memory repos
  • Common mistakes and how to avoid them

Install with Claude Code:

bun x skills add joao-coimbra/archstone

Or copy from the installed package:

cp -r node_modules/archstone/skills/use-archstone .claude/skills/

Built with ❤️ for the TypeScript community.

Contributing · Code of Conduct · MIT License

About

TypeScript architecture foundation for backend services — DDD, Clean Architecture, functional error handling

Topics

Resources

Code of conduct

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages