Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Installation

Add the architect package with your project’s package manager.

npm install @artisansdk/architect

pnpm add @artisansdk/architect

bun add @artisansdk/architect

Quick Start

This example wires up two services and mounts a React app. The same pattern applies to any framework.

import "reflect-metadata"
import { Application, ServiceProvider } from "@artisansdk/architect"
import { ContextProvider } from "@artisansdk/architect/react"
import { createRoot } from "react-dom/client"
import { createElement } from "react"
import App from "./App"

class ApiServiceProvider extends ServiceProvider {
  register(container) {
    container.singleton(ApiClient, ApiClient)
  }

  boot(container) {
    container.make(ApiClient).connect()
  }

  destroy(container) {
    container.make(ApiClient).disconnect()
  }
}

const application = Application.configure({
  config: { api: { url: "https://api.example.com" } },
})
.withProviders([new ApiServiceProvider()])
const root = createRoot(document.getElementById("root")!)
root.render(createElement(ContextProvider, { application }, createElement(App)))

The Lifecycle runs in this order every time:

  1. register() on every ServiceProvider — bindings only, no resolving
  2. boot() on every ServiceProvider — safe to resolve any binding
  3. Framework renders the root component
  4. On beforeunload, destroy() runs on every ServiceProvider, in reverse provider order

That’s all there is to it. The rest of this guide covers each piece in depth.

Application & Lifecycle

Application is the central orchestrator. You configure it with providers and inline config, then call run() to start the lifecycle.

Configuration

import { Application } from "@artisansdk/architect"

const application = Application.configure({
  config: {
    app: { name: "My App" },
    cache: { default: "memory" },
  },
}).withProviders([
  new AuthProvider(),
  new ApiProvider(),
])

Application.configure() accepts an options object:

OptionTypeDescription
configRecord<string, unknown>Inline config. If provided (even partially), file-based config discovery is skipped entirely — the two sources don’t merge. See Config.
basePathstringRoot path for config file discovery (default "./")
container{ factory?: (() => ContainerContract) | null }Supply a factory to use a custom container implementation instead of the built-in one

Running

const { container, stop } = application.run()

run() returns { container, stop }. The Application registers a beforeunload listener that calls stop() automatically, so you rarely need to call it yourself.

Lifecycle Order

The fixed sequence is:

  1. Register — every provider’s register() runs in the order they were added
  2. Boot — every provider’s boot() runs after all register() calls complete
  3. Shutdown — every provider’s destroy() runs, in reverse provider order

No phase can be skipped or reordered. This guarantee is why boot() can safely resolve any binding — all providers have already registered by the time any boot() runs.

Resolving from outside providers

After run(), you can resolve bindings anywhere via the static Application.make():

const service = Application.make(MyService)

This reads from the current Application’s container. It throws if called before run().

Service Providers

A ServiceProvider is the unit of wiring — a class that encapsulates registration and boot for one feature area. Every service you want in the container gets its own provider.

Basic structure

import { ServiceProvider, type ContainerContract as Container } from "@artisansdk/architect"

export class AnalyticsProvider extends ServiceProvider {
  protected analytics?: AnalyticsService

  register(container: Container): void {
    container.singleton(AnalyticsService, AnalyticsService)
  }

  boot(container: Container): void {
    this.analytics = container.make(AnalyticsService)
    this.analytics.start()
  }

  destroy(): void {
    this.analytics?.stop()
  }
}

The register / boot contract

register() must only bind into the container — never resolve. boot() may safely resolve any binding because all providers’ register() calls have completed first.

// ✅ correct
register(container: Container) {
  container.singleton(MyService, MyService)
}

// ❌ wrong — resolving in register() risks getting undefined
//    if another provider hasn't registered yet
register(container: Container) {
  const config = container.make(ConfigRepository) // don't do this
}

Tearing down with destroy()

register() and boot() are void — there’s no return value to track. Tear-down work goes in destroy() instead: a separate method the Application calls once per provider, in reverse provider order, on shutdown. Whatever destroy() needs (a timer handle, an AbortController, a subscription) is tracked as an instance field, since it’s no longer passed back through a return value:

export class PollingProvider extends ServiceProvider {
  protected interval?: ReturnType<typeof setInterval>

  boot(container: Container): void {
    const poller = container.make(PollingService)
    this.interval = setInterval(() => poller.tick(), 5000)
  }

  destroy(): void {
    clearInterval(this.interval)
  }
}

This matches the same convention as React’s useEffect, Svelte’s onDestroy, and Vue’s onUnmounted — providers that don’t need cleanup simply don’t override destroy().

Provider ownership

Each ServiceProvider is the sole owner of registration, booting, and cleanup for its feature area. No other code should bind or unbind what a provider manages.

Passing providers

Pass provider instances to withProviders():

Application.configure()
  .withProviders([
    new DatabaseProvider(),
    new AuthProvider(),
    new ApiProvider(),
  ])
  .run()

Providers run in the order given.

Service Container

The Service Container is a powerful tool for managing class dependencies and performing dependency injection. The container resolves constructor dependencies automatically using TypeScript’s design:paramtypes reflection metadata. Enable it in tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

And import reflect-metadata once at your entry point:

import "reflect-metadata"

Binding

Singleton

Resolved once; the same instance is returned on every subsequent make().

container.singleton(UserRepository, UserRepository)

Additionally a factory can be passed to construct the instance when it is first resolved, then the result will be cached for future resolution.

container.singleton(UserRepository, () => new UserRepository({ /* config */ }))

Transient

A new instance is created on every make().

container.bind(RequestHandler, RequestHandler)

Constant value

Registers an existing value directly. Use this for configuration objects, third-party instances, or anything already constructed.

container.instance(ApiConfig, { url: "https://api.example.com", timeout: 5000 })

Reactive

Registers a singleton whose resolved value is guaranteed to be a Valtio proxy, and tags the binding "reactive". This is the only binding kind the container is allowed to modify — and only once, going in: the concrete is wrapped the moment it’s built into the singleton, not re-touched on later make() calls. Every other binding kind returns exactly what you registered.

container.reactive(Menu, Menu)
container.reactive("cart", { items: [] })
  • Always a singleton. There is no transient reactive() — a fresh proxy per resolution would defeat sharing reactive state across everything that resolves it.
  • The container detects Valtio proxies on the way in and wraps whatever isn’t one already you may pass a plain class, object, or factory, or an already-proxied value and the container will handle it.

Fluent binding

The fluent API gives you more control over scope and factory behaviour:

// Bind to a class with explicit scope
container.bind(MyService).to(MyServiceImpl).inSingletonScope()
container.bind(MyService).to(MyServiceImpl).inTransientScope()

// Bind to a constant value
container.bind("config.url").toConstantValue("https://api.example.com")

// Bind to a factory that receives the container
container.bind(MyService).to((container) => {
  const config = container.make(ApiConfig)
  return new MyService(config.url)
})

Resolving

const service = container.make(UserRepository)

// Alias
const service = container.get(UserRepository)

Identifiers

Bindings can be keyed by class, string, or symbol:

container.singleton(UserRepository, UserRepository)         // class key
container.instance("api.url", "https://api.example.com")   // string key
container.instance(Symbol("db"), connection)               // symbol key

Auto-wiring

When you bind a class, the container reads its constructor parameter types from metadata and resolves each one automatically:

class ApiClient {
  constructor(protected config: ApiConfig, protected logger: Logger) {}
}

container.singleton(ApiConfig, ApiConfig)
container.singleton(Logger, Logger)
container.singleton(ApiClient, ApiClient)

// ApiConfig and Logger are injected automatically
const client = container.make(ApiClient)

Manual injection tokens

When a constructor parameter is typed as an interface or primitive, metadata can’t infer the token. Use @inject() to specify it explicitly:

import { inject } from "@artisansdk/architect"

class ApiClient {
  constructor(
    @inject("api.url") protected url: string,
    protected logger: Logger,
  ) {}
}

Checking bindings

container.bound(UserRepository)  // true / false
container.has("api.url")         // alias for bound()

Config

ConfigRepository is a typed key-value store with dot-notation path access. It’s registered automatically by the Application — you don’t need a provider for it.

Reading values

import { Config } from "@artisansdk/architect/support/facades"

Config.get("app.name")               // string | null
Config.get<string>("app.name")       // string | null
Config.get("app.timeout", 30)        // returns 30 if not set
Config.get("app.timeout", () => 30)  // lazy default

Dot notation traverses nested objects — "app.name" reads { app: { name: "..." } }.

Checking existence

Config.has("app.name")         // true if set and not null
Config.has(["app.name", "app.url"])  // true if all are set

Writing values

Config.set("app.name", "My App")
Config.set({ "app.name": "My App", "app.debug": true })

Arrays

Config.prepend("app.middleware", LogMiddleware)  // add to front
Config.push("app.middleware", AuthMiddleware)    // add to end

Getting multiple keys

Config.getMany(["app.name", "app.url"])
// → { "app.name": "...", "app.url": "..." }

Config.getMany({ "app.name": "default", "app.url": null })
// → uses per-key defaults

Inline config

Pass config directly to Application.configure():

Application.configure({
  config: {
    app: { name: "My App", debug: false },
    cache: { default: "memory" },
  },
})

File-based config

In a Vite project, place config files in a config/ directory:

// config/app.ts
export default {
  name: import.meta.env.VITE_APP_NAME ?? "My App",
  debug: import.meta.env.DEV,
}

The Application loads these automatically via import.meta.glob, but only when Application.configure() is called with no inline config at all. Passing any inline config — even a single unrelated key — skips file discovery entirely instead of merging with it; the two sources don’t combine. The filename becomes the top-level key — config/app.ts is available under "app.*".

Environment variables

Use the env() helper to read environment variables with an optional default:

import { env } from "@artisansdk/architect"

const url = env("VITE_API_URL", "http://localhost:3000")

env() is separate from file-based config — it reads directly from import.meta.env.

Using ConfigRepository directly

In a ServiceProvider, the ConfigRepository is bound as "config" and by class:

import { ConfigRepository, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  const config = container.make(ConfigRepository)
  const timeout = config.get<number>("api.timeout", 5000)
}

Cache

CacheManager manages TTL-based caching. It wraps raw storage adapters with TTL-aware get/set via the Cache layer — values are stored with an expiry timestamp and evicted lazily on read. Not designed as a primary data store.

Register it by including CacheProvider in your providers (or use the built-in defaultProviders):

import { Application, defaultProviders } from "@artisansdk/architect"

Application.configure().withProviders(defaultProviders).run()

Basic usage

import { Cache } from "@artisansdk/architect/support/facades"

// Set with no expiry
await Cache.set("user:42", userData)

// Set with TTL in seconds (expires after 5 minutes)
await Cache.set("user:42", userData, 300)

// Set with no expiry explicitly
await Cache.set("user:42", userData, null)

// Get — returns null if missing or expired
const user = await Cache.get<User>("user:42")

// Check existence
const exists = await Cache.has("user:42")

// Delete
await Cache.delete("user:42")

// Clear all entries
await Cache.clear()

// List non-expired keys
const keys = await Cache.keys()

TTL rules

ttl valueBehaviour
numberExpires after that many seconds
nullNo expiry
omittedNo expiry
0Expires immediately

Drivers

Three drivers are available out of the box:

DriverBacked bySurvives reloadNotes
memoryIn-memory MapNoDefault.
locallocalStorageYesFalls back to memory if unavailable.
indexedIndexedDBYesLarger capacity. Falls back to memory if unavailable.

With local and indexed drivers, cached values survive page reload — but TTL expiry is still enforced on read. A value set with a 5-minute TTL will be evicted the first time it is read after those 5 minutes, regardless of reload.

Switching drivers

// Switch the active driver
Cache.use("local")

// Access a specific driver's store directly
const memoryStore = Cache.store("memory")
await memoryStore.set("key", value)

Configuration

Application.configure({
  config: {
    cache: {
      default: "local",
      stores: {
        local: { driver: "local" },
        fast: { driver: "memory" },
      },
    },
  },
})

Registering a custom driver

Register custom drivers from a ServiceProvider’s boot() hook. The factory receives ConfigRepository and must return a raw storage Adapter:

import { CacheManager, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  const manager = container.make(CacheManager)

  manager.extend("redis", (config) => {
    return new RedisAdapter(config.get("cache.stores.redis"))
  })
}

Using CacheManager directly

import { CacheManager, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  const cache = container.make(CacheManager)
  await cache.set("session", token, 3600)
}

Store

StoreManager is an abstraction over persistent storage backends. Unlike CacheManager, values have no TTL — the Store is for durable key-value data.

Register it by including StoreProvider in your providers (or use the built-in defaultProviders):

import { Application, defaultProviders } from "@artisansdk/architect"

Application.configure().withProviders(defaultProviders).run()

Basic usage

import { Store } from "@artisansdk/architect/support/facades"

await Store.set("theme", "dark")
const theme = await Store.get<string>("theme")   // "dark" | null
const exists = await Store.has("theme")          // true
await Store.delete("theme")
await Store.clear()
const keys = await Store.keys()

Drivers

DriverBacked byNotes
memoryIn-memory MapDefault. Lost on page reload.
locallocalStorageSurvives reload. Falls back to memory if unavailable.
indexedIndexedDBLarger capacity. Falls back to memory if unavailable.

Switching drivers

Store.use("indexed")

// Access a specific driver directly
const local = Store.driver("local")
await local.set("key", value)

Configuration

Unlike CacheManager, StoreManager doesn’t support multiple named store configs — it reads a single active driver name from store.driver:

Application.configure({
  config: {
    store: {
      driver: "indexed",
    },
  },
})

Registering a custom driver

import { type StoreManager, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  // Unlike CacheManager/LogManager, StoreProvider only binds the string identifier "store" —
  // it never registers StoreManager as a class binding, so container.make(StoreManager) would
  // construct an unrelated, disconnected instance instead of resolving the shared one.
  const store = container.make<StoreManager>("store")

  store.extend("native", (config) => {
    return new TauriStoreAdapter(config.get("store.native"))
  })
}

Adapters

You can use the built-in adapters directly if you need a raw storage layer without the manager:

import {
  MemoryStoreAdapter,
  LocalStorageAdapter,
  IndexedDbAdapter,
} from "@artisansdk/architect"

const memory = new MemoryStoreAdapter()
const local = new LocalStorageAdapter(window.localStorage)
const indexed = new IndexedDbAdapter()

await memory.set("key", value)
const result = await memory.get("key")

IndexedDbAdapter options

const indexed = new IndexedDbAdapter({
  name: "my-app-store",       // database name (default: "ioc-store")
  factory: globalThis.indexedDB,  // IDBFactory (default: globalThis.indexedDB)
  fallback: new MemoryStoreAdapter(), // fallback when IDB unavailable
})

Events

The Bus is a pub/sub event bus with support for string events, class-based events, wildcard listeners, and deferred dispatch via queuing.

Register it by including EventsProvider in your providers:

import { Application, EventsProvider } from "@artisansdk/architect"

Application.configure()
  .withProviders([new EventsProvider()])
  .run()

If you’re already using defaultProviders (see Cache), "events" is bound for you — ErrorsProvider registers it too, guarded so it won’t replace an existing binding. EventsProvider is only necessary when you’re assembling your own provider list without defaultProviders.

Listening

import { Event } from "@artisansdk/architect/support/facades"

const off = Event.listen("user.created", (payload) => {
  console.log(payload)
})

// Stop listening
off()

Dispatching

await Event.dispatch("user.created", { id: 42, name: "Alice" })

// Alias
await Event.fire("user.created", { id: 42 })

Listen once

Event.once("app.ready", () => {
  console.log("App is ready")
})

Wildcard listeners

Event.listen("*", (eventName, data) => {
  console.log(eventName, data)
})

Class-based events

Define event classes for type safety:

class UserCreated {
  constructor(public readonly id: number, public readonly name: string) {}
}

Event.listen(UserCreated, (event) => {
  console.log(event.id, event.name)
})

await Event.dispatch(new UserCreated(42, "Alice"))

Add a static label to control the event name (minification-safe):

class UserCreated {
  static readonly label = "user.created"
  constructor(public readonly id: number) {}
}

Dispatchable mixin

Make a class dispatch itself:

import { Dispatchable } from "@artisansdk/architect"

class UserCreated extends Dispatchable {
  constructor(public readonly id: number) {}
}

// Constructs the instance for you — pass constructor args, not an instance
await UserCreated.dispatch(42)

Subscribers

Group related listeners into a subscriber class:

import { type EventSubscriber, type Bus } from "@artisansdk/architect"

class UserSubscriber implements EventSubscriber {
  subscribe(bus: Bus) {
    return {
      "user.created": this.onUserCreated,
      "user.deleted": this.onUserDeleted,
    }
  }

  onUserCreated(event: unknown) { /* ... */ }
  onUserDeleted(event: unknown) { /* ... */ }
}

Event.subscribe(new UserSubscriber())
// or pass the class — subscriber will be instantiated automatically
Event.subscribe(UserSubscriber)

Listening for the first truthy response

const result = await Event.until("form.validate", formData)
// Returns the first non-null, non-false listener return value

Queued events

Push an event to a queue without dispatching immediately. Flush later to dispatch all queued payloads in order:

Event.push("analytics.track", { event: "page_view", url: "/home" })
Event.push("analytics.track", { event: "page_view", url: "/about" })

// Later, when the analytics service is ready:
await Event.flush("analytics.track")

Using Bus directly

import { Bus, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  // "events" is the only registered identifier — Bus is never bound by class,
  // so container.make(Bus) would silently construct a fresh, disconnected instance.
  const bus = container.make<Bus>("events")
  bus.listen("order.placed", this.handleOrder)
}

Errors

ErrorsProvider catches uncaught errors — window errors, unhandled promise rejections, and (in React) render errors — and dispatches them onto the Events bus as a normalized ArchitectError, so you can observe them from one place instead of wiring window.addEventListener yourself.

It’s included in defaultProviders:

import { Application, defaultProviders } from "@artisansdk/architect"

Application.configure().withProviders(defaultProviders).run()

What it catches

SourceTriggerNotes
"window"window.addEventListener("error", ...)Filtered to same-origin script files — drops extension/third-party-script noise and censored cross-origin "Script error." events
"promise"window.addEventListener("unhandledrejection", ...)
"react"ErrorBoundary’s componentDidCatchOnly if you’re using @artisansdk/architect/react’s ErrorBoundary (wrapped around your app by ApplicationProvider/ContextProvider)

ErrorsProvider.boot() is a no-op when window is undefined, so it’s safe under SSR — it just won’t catch anything until it runs in a browser.

Listening

ArchitectError sets a static label = "error", so you can listen by the class or the string:

import { Event } from "@artisansdk/architect/support/facades"
import { ArchitectError } from "@artisansdk/architect"

Event.listen(ArchitectError, (error) => {
  console.error(`[${error.source}]`, error.message, error.cause)
  reportToSentry(error)
})

// Equivalent — same channel
Event.listen("error", (error) => { /* ... */ })

ArchitectError shape

class ArchitectError extends Error {
  readonly source: "window" | "promise" | "react"
  readonly cause: unknown        // the original thrown value (inherited from Error)
  readonly errorInfo?: unknown   // React's componentStack info, only present for source "react"
}

message and stack are adopted from the original error when it’s a real Error instance, so reports point at the throw site rather than the wrapper.

React error boundaries

ApplicationProvider/ContextProvider from @artisansdk/architect/react wrap your app in an ErrorBoundary automatically:

import { ContextProvider } from "@artisansdk/architect/react"

<ContextProvider application={application} errorFallback={(error) => <p>Something broke.</p>}>
  <App />
</ContextProvider>

errorFallback renders in place of the crashed subtree. Dispatch to the Events bus is a separate side effect — it only fires if "events" is bound in the container, which ErrorsProvider (or EventsProvider) provides. Without either, the fallback still renders; the error just isn’t dispatched anywhere.

Bringing your own events wiring

If you’re not using defaultProviders, ErrorsProvider still only needs "events" to exist — it registers a Bus itself, guarded so it won’t replace one you’ve already bound:

import { Application, ErrorsProvider } from "@artisansdk/architect"

Application.configure()
  .withProviders([new ErrorsProvider()])
  .run()

See Events for the Bus API itself.

Logging

LogManager routes log messages to one or more named drivers. Drivers are resolved lazily and can be swapped at runtime with .use(). Three drivers ship out of the box: console, null, and stack.

LogProvider is included in defaultProviders, so no extra setup is required for most apps:

import { Application, defaultProviders } from "@artisansdk/architect"

Application.configure().withProviders(defaultProviders).run()

Basic usage

Use the Log facade from any boot() hook or service:

import { Log } from "@artisansdk/architect/support/facades"

Log.debug("Fetching user", { userId: 42 })
Log.info("User loaded")
Log.warn("Cache miss — falling back to API")
Log.error("Request failed", { status: 500, url: "/api/users" })

All four methods accept an optional structured context object as their second argument.

Drivers

DriverBehaviour
consoleWrites to the browser console using native console.debug/info/warn/error. Respects a minimum level threshold.
nullDiscards all messages. Useful in tests.
stackFans out each call to an ordered list of other drivers. Errors thrown by individual drivers are swallowed.

Configuration

Application.configure({
  config: {
    logging: {
      default: "console",
      drivers: {
        console: { level: "warn" }, // suppress debug and info in production
      },
    },
  },
})

Level threshold

ConsoleLogger supports a minimum level. Messages below the threshold are silently dropped.

LevelWhat passes
"debug"All messages (default)
"info"info, warn, error
"warn"warn, error
"error"error only

Fan-out with the stack driver

Use stack to write to multiple drivers simultaneously. Errors thrown by any individual driver are caught and swallowed — a logging failure will never crash the application.

Application.configure({
  config: {
    logging: {
      default: "stack",
      drivers: {
        stack: { drivers: ["console", "sentry"] },
        console: { level: "debug" },
        sentry: {},
      },
    },
  },
})

Registering a custom driver

Register custom drivers from a ServiceProvider’s boot() hook. The factory receives ConfigRepository and must return an object implementing the log Contract:

import { LogManager, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  const manager = container.make(LogManager)

  manager.extend("sentry", (config) => {
    return new SentryLogger(config.get("logging.drivers.sentry.dsn"))
  })
}

The log Contract requires four methods:

interface Contract {
  debug(message: string, context?: Record<string, unknown>): void
  info(message: string, context?: Record<string, unknown>): void
  warn(message: string, context?: Record<string, unknown>): void
  error(message: string, context?: Record<string, unknown>): void
}

Switching drivers at runtime

import { Log } from "@artisansdk/architect/support/facades"

Log.use("null")   // silence all output
Log.use("stack")  // restore fan-out

Using LogManager directly

import { LogManager, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  const log = container.make(LogManager)
  log.info("Provider booted")
}

Scheduler

The Scheduler runs registered tasks on a fixed 1-second tick. Each task is configured with a timing rule, optional conditions, and a handler. Tasks are one-shot by default and are automatically removed after their handler runs.

SchedulerProvider is opt-in — it is not included in defaultProviders. Add it explicitly:

import { Application, defaultProviders, SchedulerProvider } from "@artisansdk/architect"

Application.configure()
  .withProviders([...defaultProviders, new SchedulerProvider()])
  .run()

Basic usage

Register tasks from a ServiceProvider’s boot() hook:

import { Scheduler, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  const scheduler = container.make(Scheduler)

  // Run once after 1 minute
  scheduler.do(() => showModal()).in(1, "minutes")

  // Run every hour, starting after a 5-minute delay
  scheduler.do(() => syncData()).in(5, "minutes").every(1, "hours")
}

Timing

Delay before first run — .in()

.in() sets when the task first becomes eligible to run. It accepts a numeric offset with a unit, a Date, or any object with an epochMilliseconds property (e.g. Temporal.Instant or Temporal.ZonedDateTime).

scheduler.do(fn).in(30, "seconds")
scheduler.do(fn).in(new Date("2026-07-01T09:00:00"))
scheduler.do(fn).in(Temporal.Now.instant().add({ hours: 1 }))

Supported units: "milliseconds", "seconds", "minutes", "hours".

Recurring tasks — .every()

.every() makes a task recurring. The schedule advances on a fixed cadence — the next tick is always computed from the last scheduled fire, not from the last successful run. A task whose conditions fail at a given tick will be offered again at the next interval.

scheduler.do(() => pollApi()).every(30, "seconds")

Tasks without .every() are one-shot by default and are removed after their first run.

Conditions

.when()

The handler only runs when the condition is truthy:

scheduler.do(fn).every(1, "hours").when(() => isOnline())

Supports an optional comparison operand and value:

scheduler.do(fn).every(1, "hours").when(() => retryCount, "<", 5)

Supported operands: =, ==, ===, !=, !==, <>, >, <, >=, <=.

.unless()

The handler only runs when the condition is falsy — the inverse of .when():

scheduler.do(fn).in(1, "minutes").unless(() => alreadyShownToday())

Conditions do not affect the schedule. If a condition fails at a scheduled tick, the interval still advances and the task is offered again at the next fire time.

Named tasks

Register a task by name to cancel it later without holding a reference. If a name is already in use, the existing task is removed and a warning is logged before the new one is registered.

scheduler.task("review-prompt", () => showModal()).in(1, "minutes")

// Later:
scheduler.cancel("review-prompt")

Tags

Tag tasks to cancel them as a group:

scheduler.do(() => showModal()).tag("popups").in(1, "minutes")
scheduler.do(() => showBanner()).tag("popups").every(1, "hours")

// Drop all popup tasks at once:
scheduler.cancelTag("popups")

Names and tags are separate namespaces. cancel() matches by name only; cancelTag() matches by tag only.

Cancellation

// By reference
const task = scheduler.do(fn).every(5, "minutes")
scheduler.cancel(task)

// By name
scheduler.cancel("review-prompt")

// By tag
scheduler.cancelTag("popups")

One-shot vs recurring

ConfigurationBehaviour
No .every()One-shot — runs once when conditions pass, then auto-removed.
.every(n, unit)Recurring — stays registered, fires on a fixed cadence.
.once()Explicit one-shot (same as default, useful for clarity).

Error handling

If a task handler throws, the error is caught and logged to console.warn. The task is still removed if it was one-shot, and the rest of the tasks in the tick are unaffected.

Using Scheduler directly

import { Scheduler, type ContainerContract as Container } from "@artisansdk/architect"

boot(container: Container) {
  const scheduler = container.make(Scheduler)
  scheduler.task("alert", () => showAlert())
    .when(() => !alreadySeenToday())
    .in(0, "seconds")
}

Facades

A Facade is a static proxy that forwards calls to a service resolved from the container. Facades are safe to use from boot() hooks onward — calling one before the Application has run throws.

Built-in facades

FacadeProxies
AppThe active ContainerContract itself
ConfigConfigRepository
CacheCacheManager
StoreStoreManager
EventBus
LogLogManager
import { App, Config, Cache, Store, Event, Log } from "@artisansdk/architect/support/facades"

Creating a custom facade

import { createFacade } from "@artisansdk/architect/facade"
import type MyService from "./my-service"

export const MyFacade = createFacade<MyService>("my-service")

The string "my-service" is the container binding key. Bind it in a ServiceProvider:

register(container) {
  container.singleton("my-service", MyService)
}

Macros

A Macro is a named function added to a Facade at runtime. It takes precedence over instance methods of the same name.

import { Config } from "@artisansdk/architect/facades"

Config.macro("required", (instance, key: string) => {
  const value = instance.get(key)
  if (value === null) throw new Error(`Config key "${key}" is required.`)
  return value
})

// Now callable as a regular method
const name = Config.required("app.name")

The macro receives the resolved service instance as its first argument, followed by any arguments passed at the call site.

Scoping

Macros are scoped per facade — a macro on Config does not appear on Cache.

Checking and removing macros

Config.hasMacro("required")  // true
Config.flushMacros()         // remove all macros from this facade

Resolution, not caching

A facade holds no instance of its own — every property or method access resolves the accessor fresh from the current Application’s container (Application.make(accessor)). There’s nothing to clear on shutdown or manually flush for tests.

Whether you get the same underlying instance across calls depends entirely on how that binding was registered, same as calling container.make(...) directly: a singleton() binding returns the same instance every time; a bind() (transient) binding returns a new one on each resolution.

Framework Adapters

Architect provides adapters for React, Vue, Solid, and Svelte. Each adapter integrates the Application container with the framework’s component tree so any component can resolve services without prop-drilling.

React

npm install @artisansdk/architect react

With JSX (main.tsx):

import "reflect-metadata"
import React from "react"
import ReactDOM from "react-dom/client"
import { Application } from "@artisansdk/architect"
import { ContextProvider } from "@artisansdk/architect/react"
import App from "./App"

const app = Application.configure()
  .withProviders([new AppProvider()])

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <ContextProvider application={app}>
      <App />
    </ContextProvider>
  </React.StrictMode>
)

Without JSX (main.ts):

import "reflect-metadata"
import { createElement } from "react"
import { createRoot } from "react-dom/client"
import { Application } from "@artisansdk/architect"
import { ContextProvider } from "@artisansdk/architect/react"
import App from "./App"

const app = Application.configure()
  .withProviders([new AppProvider()])

const root = createRoot(document.getElementById("root")!)
root.render(
  createElement(ContextProvider, { application: app }, createElement(App))
)

Resolving services in components

import { useService } from "@artisansdk/architect/react"
import { UserService } from "./services/user"

function Profile() {
  const userService = useService(UserService)
  // ...
}

Reactive services

Bindings registered with container.reactive(...) (see Container) are automatically subscribed to — useService detects the "reactive" tag and wraps the result in Valtio’s useProxy, so the component re-renders on mutation with no extra code:

// provider
container.reactive(Menu, Menu)

// component — mutating menu re-renders this component, same call as any other service
const menu = useService(Menu)

Services registered with bind/singleton are returned as-is; use component state, signals, or another framework mechanism for those.

Error boundaries

ApplicationProvider/ContextProvider wrap your tree in an ErrorBoundary — pass errorFallback to render something in place of a crashed subtree. See Errors for how caught errors get dispatched onto the Events bus.

Using an existing container

If you already have a container (e.g. in tests or SSR), pass it directly:

<ContextProvider container={myContainer}>
  <App />
</ContextProvider>

Hooks

HookDescription
useService(Token)Resolve a binding from the Service Container
useContainer()Access the raw ContainerContract
useSignal(signal)Read a Signal’s current value and subscribe to changes

Vue

npm install @artisansdk/architect vue

ContextProvider renders its default slot, so it needs an explicit render function to receive children — mounting it directly as createApp(ContextProvider, props) leaves the slot empty and renders nothing:

import "reflect-metadata"
import { createApp, h } from "vue"
import { Application } from "@artisansdk/architect"
import { ContextProvider } from "@artisansdk/architect/vue"
import App from "./App.vue"

const application = Application.configure()
  .withProviders([new AppProvider()])

createApp({
  render: () => h(ContextProvider, { application }, () => h(App)),
}).mount("#root")

Resolving services in components

import { useService } from "@artisansdk/architect/vue"
import { UserService } from "./services/user"

const userService = useService(UserService)

Or inject the container directly:

import { inject } from "vue"
import { containerKey } from "@artisansdk/architect/vue"
import { UserService } from "./services/user"

const container = inject(containerKey)!
const userService = container.make(UserService)

Solid

npm install @artisansdk/architect solid-js
import "reflect-metadata"
import { render } from "solid-js/web"
import { Application } from "@artisansdk/architect"
import { ContextProvider } from "@artisansdk/architect/solid"

const application = Application.configure()
  .withProviders([new AppProvider()])

render(
  () => <ContextProvider application={application}><App /></ContextProvider>,
  document.getElementById("root")!
)

Svelte

npm install @artisansdk/architect svelte

Svelte has no ContextProvider component. Call application.run() yourself, pass the resulting container into your root component as a prop, and call provideContainer(...) inside it before any useService(...) calls:

// main.ts
import "reflect-metadata"
import { Application } from "@artisansdk/architect"
import App from "./App.svelte"

const application = Application.configure()
  .withProviders([new AppProvider()])

const running = application.run()

new App({
  target: document.getElementById("root")!,
  props: { container: running.container },
})

window.addEventListener("beforeunload", running.stop, { once: true })
<!-- App.svelte -->
<script lang="ts">
  import { provideContainer, useService } from "@artisansdk/architect/svelte"
  import { UserService } from "./services/user"

  export let container: unknown

  provideContainer(container as never)
  const userService = useService(UserService)
</script>

Utilities

Architect ships several Laravel-inspired utility classes. Each is a separate subpath export — import only what you use.

Str

String manipulation utilities, matching Laravel’s Str helper. All methods are static functions on the Str object.

import { Str } from "@artisansdk/architect"

Str.slug("Hello World")              // "hello-world"
Str.camel("user_created")            // "userCreated"
Str.snake("UserCreated")             // "user_created"
Str.kebab("UserCreated")             // "user-created"
Str.studly("user_created")           // "UserCreated"
Str.title("hello world")             // "Hello World"
Str.headline("user_created_event")   // "User Created Event"
Str.limit("Long sentence here", 10)  // "Long sente..."
Str.lower("HELLO")                   // "hello"
Str.upper("hello")                   // "HELLO"
Str.random(16)                       // random alphanumeric string
Str.contains("hello world", "world") // true
Str.startsWith("hello", "hel")       // true
Str.endsWith("hello", "llo")         // true
Str.replace("world", "there", "hello world") // "hello there"
Str.slug("Héllo Wörld")              // "hello-world"
Str.trim("  hello  ")               // "hello"
Str.squish("hello   world")          // "hello world"
Str.after("user@example.com", "@")   // "example.com"
Str.before("user@example.com", "@")  // "user"
Str.between("<div>", "<", ">")       // "div"
Str.wordCount("hello world")         // 2
Str.isUrl("https://example.com")     // true
Str.isJson('{"key":"value"}')        // true
Str.toBase64("hello")                // "aGVsbG8="
Str.fromBase64("aGVsbG8=")           // "hello"

registerGlobalHelpers() makes any utility available on globalThis so it’s accessible anywhere without importing. Pass only what you need — anything you don’t import is treeshaken out of the bundle:

import { registerGlobalHelpers, Str, Num, Arr } from "@artisansdk/architect"

registerGlobalHelpers({ Str, Num, Arr })

// Anywhere in the app, no import needed:
Str.slug("Hello World")
Num.currency(9.99, "USD")

The object shorthand { Str, Num, Arr } uses the variable names as the keys on globalThis. You can rename a helper if needed:

registerGlobalHelpers({ S: Str }) // → globalThis.S

Arr

Array utilities, matching Laravel’s Arr helper:

import { Arr } from "@artisansdk/architect"

Arr.wrap("hello")          // ["hello"]
Arr.wrap(["hello"])        // ["hello"]
Arr.wrap(null)             // []

Arr.flatten([[1, 2], [3]]) // [1, 2, 3]
Arr.first([1, 2, 3])       // 1
Arr.last([1, 2, 3])        // 3

const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
]

Arr.pluck(users, "name")   // ["Alice", "Bob"]
Arr.keyBy(users, "id")     // { 1: { id: 1, name: "Alice" }, 2: { ... } }

Num

Number formatting utilities, matching Laravel’s Number helper:

import { Num } from "@artisansdk/architect"

Num.format(1234567.89)          // "1,234,567.89"
Num.format(1234.5, 2)           // "1,234.50"
Num.currency(9.99, "USD")       // "$9.99"
Num.currency(9.99, "EUR", "de") // locale-specific format
Num.percentage(75)              // "75%"
Num.percentage(33.3, 1)         // "33.3%"
Num.fileSize(1536)              // "2 KB"
Num.fileSize(1048576, 1)        // "1.0 MB"
Num.abbreviate(1500)            // "2K"
Num.abbreviate(1500000, 1)      // "1.5M"
Num.clamp(150, 0, 100)          // 100
Num.clamp(-5, 0, 100)           // 0
Num.between(5, 1, 10)           // true
Num.between(15, 1, 10)          // false

Collection

An immutable, chainable wrapper around arrays, matching Laravel’s Collection:

import { Collection } from "@artisansdk/architect"

const users = new Collection([
  { id: 1, name: "Alice", age: 30 },
  { id: 2, name: "Bob", age: 25 },
])

users.filter((u) => u.age > 20).map((u) => u.name).toArray()
// ["Alice", "Bob"]

users.first()              // { id: 1, name: "Alice", age: 30 }
users.last()               // { id: 2, name: "Bob", age: 25 }
users.count()              // 2
users.pluck("name")        // Collection ["Alice", "Bob"]
users.keyBy("id")          // Collection { 1: { ... }, 2: { ... } }
users.groupBy("age")       // Collection { 25: [...], 30: [...] }
users.sum("age")           // 55
users.avg("age")           // 27.5
users.contains((u) => u.name === "Alice") // true
users.toArray()            // original array

LazyCollection

Like Collection but lazily evaluated — values are not computed until you iterate or call toArray(). Useful for large datasets where you want to avoid building intermediate arrays:

import { LazyCollection } from "@artisansdk/architect"

const result = new LazyCollection(largeArray)
  .filter((x) => x.active)
  .map((x) => x.id)
  .take(100)
  .toArray()

Fluent

A generic dot-notation key-value wrapper. Useful for wrapping configuration objects or arbitrary records with a clean read/write API:

import { Fluent } from "@artisansdk/architect"

const obj = new Fluent({
  user: { name: "Alice", age: 30 },
  settings: { theme: "dark" },
})

obj.get("user.name")              // "Alice"
obj.get("user.missing", "guest")  // "guest"
obj.get<number>("user.age")       // 30
obj.has("settings.theme")         // true
obj.set("user.age", 31)           // returns this (chainable)
obj.toArray()                     // { user: { name: "Alice", age: 31 }, ... }

Signal

A minimal observable value box — get/set/subscribe, no dependency tracking or batching:

import { Signal } from "@artisansdk/architect"

const count = new Signal(0)

const unsubscribe = count.subscribe((value) => console.log("count is now", value))

count.set(1)              // logs "count is now 1"
count.update((n) => n + 1) // logs "count is now 2"
count.get()                // 2

unsubscribe()

set() is a no-op if the new value is Object.is-equal to the current one — listeners aren’t notified. In React, useSignal(signal) subscribes a component to a Signal and re-renders on change.

Pipeline

Send a value through a series of transform functions, matching Laravel’s Pipeline:

import { send } from "@artisansdk/architect"

// Each pipe is a function: (passable, next) => result
// Call next(passable) to pass to the next stage.
const validate = (user, next) => {
  if (!user.name) throw new Error("Name is required")
  return next(user)
}

const normalizeEmail = (user, next) => {
  return next({ ...user, email: user.email.toLowerCase() })
}

const result = send(user)
  .through([validate, normalizeEmail])
  .thenReturn()

Use then() to provide a final destination instead of returning the passable:

const result = send(user)
  .through([validate, normalizeEmail])
  .then((user) => repository.save(user))

The pipeline is synchronous. If you need async pipes, resolve promises inside each pipe before calling next:

const fetchProfile = async (user, next) => {
  const profile = await api.getProfile(user.id)
  return next({ ...user, profile })
}

// Once any pipe awaits before calling next, the whole chain's result becomes
// a Promise — await the call site, not the individual pipes.
const result = await send(user)
  .through([validate, fetchProfile])
  .thenReturn()

Deferrable Providers

A DeferrableServiceProvider declares which container bindings it owns via provides(). The Application skips register() and boot() entirely until one of those bindings is actually resolved from the container — an optimization for services that aren’t always needed in a given session.

Basic usage

import { DeferrableServiceProvider, type ContainerContract as Container } from "@artisansdk/architect"

export class ReportingProvider extends DeferrableServiceProvider {
  provides(): string[] {
    return ["reporting", "reporting.exporter"]
  }

  register(container: Container): void {
    container.singleton("reporting", ReportingService)
    container.singleton("reporting.exporter", PdfExporter)
  }

  boot(container: Container): void {
    // Only runs the first time "reporting" or "reporting.exporter" is resolved
    container.make(ReportingService).connect()
  }
}

Register it the same way as any other provider:

Application.configure()
  .withProviders([new ReportingProvider()])
  .run()

Nothing else changes at the call site — container.make("reporting") (directly, via container.get(...), or indirectly through anything that resolves it) transparently triggers register() then boot() the first time, then resolves normally. Every later resolution of any of the provider’s declared identifiers — including the other ones it didn’t originally trigger on — hits the real binding directly; register()/boot() never run twice.

When to use it

Use a DeferrableServiceProvider when:

  • The service does expensive initialization in boot() (network connections, large allocations)
  • The service is only needed on certain routes or user flows
  • You want to avoid paying boot cost for services that may never be used in a given session

When not to use it

If the service is always resolved (e.g. bound to a component that renders on every page), deferral adds overhead with no benefit. Use a regular ServiceProvider instead.

Caveats

provides() must be exhaustive. Only the identifiers it lists get the lazy hook. If register() binds something not listed in provides(), that binding simply won’t exist until some declared identifier is resolved and drags the rest along with it — there’s no validation catching an incomplete list.

An empty provides() disables deferral, not the provider. DeferrableServiceProvider’s default provides() returns []. With nothing to hook, the Application falls back to booting it eagerly — same as a regular ServiceProvider — rather than never booting it at all.

bound()/has() don’t know about deferred bindings. Only make()/get() trigger the lazy boot. container.bound("reporting") returns false until something has actually resolved it — checking bound() first to decide whether to resolve a deferred binding will defeat the deferral (and get the wrong answer).

destroy() only runs for providers that actually booted. A deferred provider nothing ever resolved never boots, so its destroy() is skipped too — there’s nothing to tear down. Shutdown order is reverse of actual boot order, not registration order: if a deferred provider gets triggered mid-session, well after every eager provider has already booted, it’s still destroyed first — same LIFO discipline as eager providers, just anchored to when each one really started.

Both string and class identifiers work in provides() — whatever Identifier accepts elsewhere in the container works here too:

provides() { return [ReportingService] }
register(container: Container) { container.singleton(ReportingService, ReportingService) }

Custom Drivers

Both CacheManager and StoreManager support registering custom drivers via extend(). A driver is a named backend — register it from a ServiceProvider’s boot() hook, where the manager is already bound.

Custom Store driver

Implement the StoreAdapter interface:

import type { StoreAdapter } from "@artisansdk/architect"

class RedisAdapter implements StoreAdapter {
  constructor(private client: RedisClient) {}

  async get<T>(key: string): Promise<T | null> {
    const value = await this.client.get(key)
    return value === null ? null : JSON.parse(value)
  }

  async set<T>(key: string, value: T): Promise<void> {
    await this.client.set(key, JSON.stringify(value))
  }

  async has(key: string): Promise<boolean> {
    return (await this.client.exists(key)) === 1
  }

  async delete(key: string): Promise<void> {
    await this.client.del(key)
  }

  async clear(): Promise<void> {
    await this.client.flushDb()
  }

  async keys(): Promise<string[]> {
    return this.client.keys("*")
  }
}

Register it in a ServiceProvider:

import { ServiceProvider, StoreManager, type ContainerContract as Container } from "@artisansdk/architect"

export class RedisStoreProvider extends ServiceProvider {
  boot(container: Container): void {
    // StoreManager isn't bound by class — only the string identifier "store" is registered.
    const store = container.make<StoreManager>("store")

    store.extend("redis", (config) => {
      const url = config.get<string>("store.redis.url", "redis://localhost:6379")
      return new RedisAdapter(new RedisClient(url))
    })
  }
}

Then configure it as the active driver. Unlike CacheManager, StoreManager has no stores map in its config shape — it reads a single flat store.driver key, and any config a custom driver’s factory needs is just a plain key you choose and read back yourself:

Application.configure({
  config: {
    store: {
      driver: "redis",
      redis: { url: "redis://localhost:6379" },
    },
  },
}).withProviders([new RedisStoreProvider()])

Custom Cache driver

Cache drivers use the same StoreAdapter interface — the Cache TTL wrapper is applied automatically by CacheManager. You do not need to implement TTL yourself:

import { ServiceProvider, CacheManager, type ContainerContract as Container } from "@artisansdk/architect"

export class RedisCacheProvider extends ServiceProvider {
  boot(container: Container): void {
    const cache = container.make(CacheManager)

    cache.extend("redis", (config) => {
      const url = config.get<string>("cache.stores.redis.url")
      return new RedisAdapter(new RedisClient(url))
    })
  }
}

Set cache.default: "redis" (or call Cache.use("redis") later) to activate it — cache.stores.redis here is just where this example chose to stash the driver’s own config; CacheManager only reads .driver out of cache.stores entries for its three built-in drivers, so a custom driver’s config path is otherwise up to you, same as StoreManager above.

Driver factory signature

The factory callback receives ConfigRepository and must return a raw StoreAdapter:

manager.extend("my-driver", (config: ConfigRepository): StoreAdapter => {
  return new MyAdapter(config.get("store.my-driver"))
})

The factory is called lazily — only when the driver is first accessed — and the result is cached for subsequent calls.

Switching drivers at runtime

import { Store } from "@artisansdk/architect/facades"

Store.use("redis")
await Store.set("user:42", userData)

// Switch back
Store.use("memory")