DEV Community

Theodor Diaconu
Theodor Diaconu

Posted on

Rethinking TypeScript Architecture: From Classes to Composable Functions

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:

  1. DI without the Ceremony: Resources that initialize asynchronously.
  2. Tasks as Primitives: Your business logic as focused, testable functions.
  3. Composable Middleware: Cross-cutting concerns as plug-and-play wrappers.
  4. 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

This is powerful for two reasons:

  1. Clarity: The function's dependencies and input shape are declared upfront. The logic is focused.
  2. Trivial Testing: A task's run method can be tested directly by passing its input and mock dependencies. No container, no TestBed.
// It's just a function call.
const user = await createUser.run(
  { name: "Ada", email: "ada@example.com" },
  { db: mockDatabase, logger: mockLogger },
);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)