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 @raubjo/architect-core

pnpm add @raubjo/architect-core

bun add @raubjo/architect-core

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 "@raubjo/architect-core"
import { ContextProvider } from "@raubjo/architect-core/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 }) {
    const client = container.make(ApiClient)
    client.connect()

    return () => client.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, cleanup functions run in reverse 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 "@raubjo/architect-core"

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 merged with file-based config
basePathstringRoot path for config file discovery (default "./")
containerobjectContainer adapter options

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 — cleanup functions collected from register() and boot() run in reverse 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 ServiceProviderContext } from "@raubjo/architect-core"

export class AnalyticsProvider extends ServiceProvider {
  register({ container }: ServiceProviderContext): void {
    container.singleton(AnalyticsService, AnalyticsService)
  }

  boot({ container }: ServiceProviderContext): void | (() => void) {
    const analytics = container.make(AnalyticsService)
    analytics.start()

    return () => 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.singleton(MyService, MyService)
}

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

Returning a cleanup function

Both register() and boot() can return a cleanup function. The Application collects these and calls them in reverse order on shutdown.

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

  return () => clearInterval(interval)
}

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

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 — matching the role of Laravel’s service container. The built-in implementation (BuiltinContainer) 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)

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

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 "@raubjo/architect-core"

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 "@raubjo/architect-core/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. 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 "@raubjo/architect-core"

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 from "@raubjo/architect-core"

boot({ 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 "@raubjo/architect-core"

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

Basic usage

import { Cache } from "@raubjo/architect-core/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 ServiceProviderContext } from "@raubjo/architect-core"

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

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

Using CacheManager directly

import CacheManager from "@raubjo/architect-core"

boot({ 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 "@raubjo/architect-core"

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

Basic usage

import { Store } from "@raubjo/architect-core/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

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

Registering a custom driver

import StoreManager from "@raubjo/architect-core"

boot({ container }) {
  const store = container.make(StoreManager)

  store.extend("native", (config) => {
    return new TauriStoreAdapter(config.get("store.stores.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 "@raubjo/architect-core"

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 "@raubjo/architect-core"

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

Listening

import { Event } from "@raubjo/architect-core/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 "@raubjo/architect-core"

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

// Dispatches via the Event facade
await UserCreated.dispatch(new UserCreated(42))

Subscribers

Group related listeners into a subscriber class:

import { type EventSubscriber, type Bus } from "@raubjo/architect-core"

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 } from "@raubjo/architect-core"

boot({ container }) {
  const bus = container.make(Bus)
  bus.listen("order.placed", this.handleOrder)
}

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 "@raubjo/architect-core"

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

Basic usage

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

import { Log } from "@raubjo/architect-core/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 ServiceProviderContext } from "@raubjo/architect-core"

boot({ container }: ServiceProviderContext) {
  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 "@raubjo/architect-core/support/facades"

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

Using LogManager directly

import { LogManager } from "@raubjo/architect-core"

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

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

FacadeProxiesImport
ConfigConfigRepository@raubjo/architect-core/support/facades
CacheCacheManager@raubjo/architect-core/support/facades
StoreStoreManager@raubjo/architect-core/support/facades
EventBus@raubjo/architect-core/support/facades
import { Config, Cache, Store, Event } from "@raubjo/architect-core/support/facades"

Creating a custom facade

import { createFacade } from "@raubjo/architect-core/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 "@raubjo/architect-core/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

Instance caching

Facades cache the resolved service instance after first use. The cache is cleared automatically on Application shutdown and when the Application is re-started.

If you need to force re-resolution (e.g. in tests):

import { clearFacadeCache } from "@raubjo/architect-core/facade"

clearFacadeCache()

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 @raubjo/architect-core react

With JSX (main.tsx):

import "reflect-metadata"
import React from "react"
import ReactDOM from "react-dom/client"
import { Application } from "@raubjo/architect-core"
import { ContextProvider } from "@raubjo/architect-core/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 "@raubjo/architect-core"
import { ContextProvider } from "@raubjo/architect-core/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 "@raubjo/architect-core/react"
import { UserService } from "./services/user"

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

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

Vue

npm install @raubjo/architect-core vue
import "reflect-metadata"
import { createApp, createElement } from "vue"
import { Application } from "@raubjo/architect-core"
import { ContextProvider } from "@raubjo/architect-core/vue"
import App from "./App.vue"

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

createApp(ContextProvider, { application })
  .component("App", App)
  .mount("#root")

Injecting services in components

import { inject } from "vue"
import { containerKey } from "@raubjo/architect-core/vue"
import { UserService } from "./services/user"

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

Solid

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

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

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

Svelte

npm install @raubjo/architect-core svelte
<script lang="ts">
  import { Application } from "@raubjo/architect-core"
  import { ContextProvider } from "@raubjo/architect-core/svelte"
  import App from "./App.svelte"

  const application = Application.configure()
    .withProviders([new AppProvider()])
</script>

<ContextProvider {application}>
  <App />
</ContextProvider>

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 "@raubjo/architect-core"

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 "@raubjo/architect-core"

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 "@raubjo/architect-core"

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

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

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 "@raubjo/architect-core"

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") // "9,99 €"
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 "@raubjo/architect-core"

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("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 "@raubjo/architect-core"

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 "@raubjo/architect-core"

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 }, ... }

Pipeline

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

import { send } from "@raubjo/architect-core"

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

Deferrable Providers

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

Basic usage

import { DeferrableServiceProvider, type ServiceProviderContext } from "@raubjo/architect-core"

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

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

  boot({ container }: ServiceProviderContext): void {
    // Heavy initialization only runs if "reporting" or "reporting.exporter"
    // is actually resolved
    container.make(ReportingService).connect()
  }
}

Register it the same way as any other provider:

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

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

The provides() list must be exhaustive. If a binding is registered in register() but not listed in provides(), it will be treated as undeferred and the provider will be booted eagerly.

Class-based identifiers are not supported in provides() — use string keys for deferrable bindings:

// ✅
provides() { return ["reporting"] }
register({ container }) { container.singleton("reporting", ReportingService) }

// ❌ class keys cannot be declared in provides()
provides() { return [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 "@raubjo/architect-core"

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 { StoreManager, type ServiceProviderContext } from "@raubjo/architect-core"

export class RedisStoreProvider extends ServiceProvider {
  boot({ container }: ServiceProviderContext): void {
    const store = container.make(StoreManager)

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

Then configure it as the active driver:

Application.configure({
  config: {
    store: {
      default: "redis",
      stores: {
        redis: { driver: "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 { CacheManager, type ServiceProviderContext } from "@raubjo/architect-core"

export class RedisCacheProvider extends ServiceProvider {
  boot({ container }: ServiceProviderContext): 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))
    })
  }
}

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.stores.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 "@raubjo/architect-core/facades"

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

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