If you've worked on a large TypeScript backend, you've seen the pattern: a sea of @Injectable()
services, each growing to dozens of methods. Business logic gets tangled, constructors fill up with dependencies, and unit testing becomes a heavy ceremony of mocking complex class instances.
This class-based approach, inherited from languages like Java and C#, has served us well. But what if it's not the most natural fit for TypeScript? What if we could build simpler, more testable, and more scalable applications by embracing functions as our core building block?
This isn't about abandoning structure; it's about finding a better one. Let's explore an architecture built on four simple, functional concepts:
- DI without the Ceremony: Resources that initialize asynchronously.
- Tasks as Primitives: Your business logic as focused, testable functions.
- Composable Middleware: Cross-cutting concerns as plug-and-play wrappers.
- A Decoupled Event Bus: Side effects handled with clean event listeners.
1. DI Without the Ceremony
Dependency Injection is powerful, but it often comes with boilerplate. Decorators are still an experimental feature, and managing the lifecycle of services (especially with async setup) can be awkward.
Consider the typical async setup in a class-based framework:
// The "Old Way": Constructor + Lifecycle Hook
@Injectable()
export class DatabaseService {
private connection: Connection;
constructor(@Inject("CONFIG") private config: Config) {
// Can't do async work here!
}
async onModuleInit() {
// So you do it here instead.
this.connection = await connect(this.config.databaseUrl);
}
}
This splits your setup logic and forces a testing ceremony.
With a functional approach, initialization is a single, async step. We call these shared singletons Resources.
// The "Runner Way": A single async `init` function
const database = r
.resource("db")
.dependencies({ config: configResource })
.init(async (_, { config }) => {
// Await whatever you need.
const connection = await connect(config.databaseUrl);
return connection;
})
.build();
Dependencies are explicitly listed, and the init
function serves as your async constructor. It's simple, predictable, and easy to test—just call init
with mock dependencies.
2. Tasks: Your Business Logic, Focused
Instead of a UserService
class with create
, update
, delete
, and get
methods, what if each of those was a standalone, composable function? We call these Tasks.
A Task is more than just a function; it's a unit of business logic with declared dependencies, optional input/output schemas, and support for middleware.
// The "Old Way": A method on a service
@Injectable()
export class UserService {
constructor(private db: DatabaseService, private logger: Logger) {}
async createUser(data: CreateUserDTO) {
this.logger.info("Creating user...");
const user = await this.db.users.create(data);
return user;
}
}
Testing this requires instantiating UserService
with mocks.
A Task, however, is self-contained.
// The "Runner Way": A standalone, testable Task
const createUser = r
.task("users.create")
.dependencies({ db: database, logger })
.inputSchema(z.object({ name: z.string(), email: z.string() }))
.run(async (input, { db, logger }) => {
logger.info(`Creating user: ${input.email}`);
const user = await db.users.insert(input);
return user;
})
.build();
This is powerful for two reasons:
- Clarity: The function's dependencies and input shape are declared upfront. The logic is focused.
- Trivial Testing: A task's
run
method can be tested directly by passing its input and mock dependencies. No container, noTestBed
.
// It's just a function call.
const user = await createUser.run(
{ name: "Ada", email: "ada@example.com" },
{ db: mockDatabase, logger: mockLogger },
);
3. Middleware: Isolate Your Cross-Cutting Concerns
Where do you put authentication, caching, logging, or retry logic? In a class-based world, you might use decorators or stuff the logic directly into your service methods. This clutters your business logic.
Middleware allows you to wrap Tasks with these concerns in a clean, composable way.
Let's add authentication and retry logic to our createUser
task:
const createUser = r
.task("users.create")
.dependencies({ db: database, logger })
.inputSchema(z.object({ name: z.string(), email: z.string() }))
// ✨ Just add middleware ✨
.middleware([
authMiddleware.with({ requiredRole: "admin" }),
retryMiddleware.with({ retries: 3 }),
])
.run(async (input, { db, logger }) => {
// The business logic remains pure.
logger.info(`Creating user: ${input.email}`);
const user = await db.users.insert(input);
return user;
})
.build();
The core run
function is unchanged. The concerns are layered on top, like an onion. Each piece of middleware is a reusable, focused function that can be applied to any task.
4. The Event Bus: Decouple Your Side Effects
What happens after a user is created? You probably need to send a welcome email, update a sales dashboard, and sync to a CRM.
The wrong way is to pack all of that into the createUser
task. This creates tight coupling and makes the task bloated and fragile.
A better way is to emit an Event.
// 1. Define an event
const userCreated = r
.event("users.created")
.payloadSchema(z.object({ userId: z.string(), email: z.string() }))
.build();
// 2. Emit it from your task
const createUser = r
.task("users.create")
.dependencies({ db, logger, userCreated }) // Depend on the event
.run(async (input, { db, logger, userCreated }) => {
const user = await db.users.insert(input);
// Fire and forget
await userCreated({ userId: user.id, email: user.email });
return user;
})
.build();
Now, other parts of your system can react to this event using Hooks without createUser
knowing or caring about them.
// 3. React to the event in a separate Hook
const sendWelcomeEmail = r
.hook("hooks.sendWelcomeEmail")
.on(userCreated) // Listen for the event
.dependencies({ emailService })
.run(async (event, { emailService }) => {
// The hook has its own dependencies and logic
await emailService.sendWelcome(event.data.email);
})
.build();
This is true decoupling. The sendWelcomeEmail
logic can be tested in isolation, and the createUser
task has a single, clear responsibility.
Putting It All Together
Here’s how these pieces form a complete, runnable application.
import { r, run, globals } from "@bluelibs/runner";
// --- SHARED RESOURCES ---
const logger = globals.resources.logger;
const database = r
.resource("db")
.init(async () => connectToDb())
.build();
const emailService = r
.resource("email")
.init(async () => createEmailer())
.build();
// --- EVENTS ---
const userCreated = r.event("users.created").payloadSchema(/*...*/).build();
// --- MIDDLEWARE ---
const authMiddleware = r.middleware.task("auth").run(/*...*/).build();
// --- BUSINESS LOGIC (TASKS) ---
const createUser = r
.task("users.create")
.dependencies({ db: database, logger, userCreated })
.middleware([authMiddleware])
.run(async (input, { db, logger, userCreated }) => {
const user = await db.users.insert(input);
await userCreated.emit({ userId: user.id, email: user.email });
return user;
})
.build();
// --- SIDE EFFECTS (HOOKS) ---
const sendWelcomeEmail = r
.hook("hooks.sendWelcomeEmail")
.on(userCreated)
.dependencies({ emailService, logger })
.run(async (event, { emailService, logger }) => {
await emailService.sendWelcome(event.data.email);
logger.info(`Welcome email sent to ${event.data.email}`);
})
.build();
// --- APPLICATION ---
const app = r
.resource("app")
.register([
logger,
database,
emailService,
userCreated,
createUser,
sendWelcomeEmail,
])
.build();
// Run the application
await run(app);
This architecture is flat, explicit, and composes beautifully. It encourages you to build applications from small, testable, and reusable functions, leading to a codebase that is easier to reason about, maintain, and scale.
Ready to ditch the bloated services? Give a functional approach a try.
Resources:
- 🌐 Website: runner.bluelibs.com
- 💻 GitHub: github.com/bluelibs/runner
- 📖 Docs: bluelibs.github.io/runner
Top comments (0)