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:
register()on every ServiceProvider — bindings only, no resolvingboot()on every ServiceProvider — safe to resolve any binding- Framework renders the root component
- 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:
| Option | Type | Description |
|---|---|---|
config | Record<string, unknown> | Inline config merged with file-based config |
basePath | string | Root path for config file discovery (default "./") |
container | object | Container 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:
- Register — every provider’s
register()runs in the order they were added - Boot — every provider’s
boot()runs after allregister()calls complete - Shutdown — cleanup functions collected from
register()andboot()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 value | Behaviour |
|---|---|
number | Expires after that many seconds |
null | No expiry |
| omitted | No expiry |
0 | Expires immediately |
Drivers
Three drivers are available out of the box:
| Driver | Backed by | Survives reload | Notes |
|---|---|---|---|
memory | In-memory Map | No | Default. |
local | localStorage | Yes | Falls back to memory if unavailable. |
indexed | IndexedDB | Yes | Larger 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
| Driver | Backed by | Notes |
|---|---|---|
memory | In-memory Map | Default. Lost on page reload. |
local | localStorage | Survives reload. Falls back to memory if unavailable. |
indexed | IndexedDB | Larger 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
| Driver | Behaviour |
|---|---|
console | Writes to the browser console using native console.debug/info/warn/error. Respects a minimum level threshold. |
null | Discards all messages. Useful in tests. |
stack | Fans 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.
| Level | What 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
| Facade | Proxies | Import |
|---|---|---|
Config | ConfigRepository | @raubjo/architect-core/support/facades |
Cache | CacheManager | @raubjo/architect-core/support/facades |
Store | StoreManager | @raubjo/architect-core/support/facades |
Event | Bus | @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
| Hook | Description |
|---|---|
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")