DEV Community

Aníbal Jorquera
Aníbal Jorquera

Posted on

Serverless on AWS without infrastructure boilerplate

Building serverless applications on AWS is incredibly powerful, but the developer experience is often fragmented.

To build a simple backend, you usually need to wire together API Gateway, Lambda, SQS, DynamoDB, Step Functions, S3, EventBridge, IAM roles, and more. Even with tools like AWS CDK or Terraform, you still spend a significant amount of time defining infrastructure instead of focusing on application logic.

I kept asking myself: What if you could define your entire serverless application in TypeScript — and let the infrastructure be generated automatically?

That's why I built Lafken.

Let's build something real

Imagine you're building an order management system. You need:

  • An API to create and query orders
  • A queue to process payments asynchronously
  • An event bus to notify other services when an order is placed
  • A workflow to orchestrate the fulfillment process
  • A table to store orders
  • A bucket to store invoices
  • A scheduled job to clean up expired orders

In a traditional setup, you'd spend hours defining each resource, wiring permissions, and connecting everything. With Lafken, you describe each piece with a decorator and the framework takes care of the rest.

Storing orders

import { Table, PartitionKey, SortKey, Field, type PrimaryPartition } from '@lafken/dynamo/main';
import { createRepository } from '@lafken/dynamo/service';

@Table({
  name: 'orders',
  indexes: [
    { type: 'global', name: 'by_status', partitionKey: 'status', sortKey: 'createdAt' },
  ],
  ttl: 'expiresAt',
})
export class Order {
  @PartitionKey(String)
  orderId: PrimaryPartition<string>;

  @SortKey(String)
  customerId: PrimaryPartition<string>;

  @Field()
  status: string;

  @Field()
  total: number;

  @Field()
  createdAt: string;

  @Field()
  expiresAt: number;
}

export const orderRepository = createRepository(Order);
Enter fullscreen mode Exit fullscreen mode

This generates a DynamoDB table with a global secondary index, TTL configuration, and the correct attribute definitions. No CloudFormation, no Terraform — just a class.

Receiving orders through an API

import { Api, Get, Post, Event, ApiRequest, PathParam, BodyParam, PathParam, ContextParam } from '@lafken/api/main';
import { orderRespository } from '../tables/order';

@ApiRequest()
class BasePayload {
  @ContextParam({
    name: 'authorizer.principalId'
  })
  customerId: string;
}

@ApiRequest()
class CreateOrderPayload extends BasePayload {
  @BodyParam({ min: 1 })
  total: number;
}

@ApiRequest()
class GetPayload extends BasePayload {
  @PathParam()
  id: number;
}

@Api({ path: '/orders' })
export class OrderApi {
  @Post()
  create(@Event(CreateOrderPayload) e: CreateOrderPayload) {
    const id = `oc-${new Date().getTime()}`;

    // send event or message queue
    return orderRespository.create({
      orderId: id,
      customerId: e.customerId,
      total: e.total,
      //...
    });
  }

  @Get({ path: '/{id}' })
  getById(@Event(GetPayload) e: GetPayload) {
    return orderRespository.findOne({
      keyCondition: {
        partition: {
          orderId: e.id,
        },
        sort: {
          customerId: e.customerId,
        },
      },
   });
  }
}
Enter fullscreen mode Exit fullscreen mode

Each method becomes a Lambda function behind API Gateway. Request validation is built-in through the @BodyParam and @PathParam decorators.

Processing payments asynchronously

import { Queue, Standard, Fifo, Payload, Param, Event } from '@lafken/queue/main';

interface MessageBody {
  orderId: string;
  customerId: string;
  // ...
}

@Payload()
class PaymentMessage {
  @Param({ source: 'body', parse: true })
  message: MessageBody;
}

@Queue()
export class PaymentQueue {
  @Fifo({ queueName: 'payment-processing', contentBasedDeduplication: true })
  process(@Event(PaymentMessage) message: PaymentMessage) {
    // Messages are processed in exact send order
    // No duplicate payments
  }
}
Enter fullscreen mode Exit fullscreen mode

This creates a FIFO SQS queue, a Lambda consumer, and the event source mapping between them. The @Payload decorator maps message attributes to typed properties.

Reacting to events

import { EventRule, Rule, Event } from '@lafken/event/main';

@EventRule()
export class OrderEvents {
  @Rule({
    pattern: {
      source: 'order-service',
      detailType: ['order.placed'],
    },
  })
  onOrderPlaced(@Event() event: any) {
    // Notify warehouse, send confirmation email, update analytics...
  }

  @Rule({
    integration: 's3',
    pattern: {
      detailType: ['Object Created'],
      detail: {
        bucket: { name: ['order-invoices'] },
        object: { key: [{ prefix: 'invoices/' }] },
      },
    },
  })
  onInvoiceUploaded(@Event() event: any) {
    // Process the invoice automatically
  }
}
Enter fullscreen mode Exit fullscreen mode

Each @Rule listens to specific EventBridge patterns. You can react to custom events, S3 notifications, DynamoDB streams — all with the same decorator.

Orchestrating fulfillment

import { StateMachine, Task } from '@lafken/state-machine/main';

@StateMachine({ 
  name: 'order-fulfillment',
  startAt: 'validatePayment'
})
export class OrderFulfillment {
  @Task({ next: 'reserveInventory' })
  validatePayment() { /* ... */ }

  @Task({ next: 'shipOrder' })
  reserveInventory() { /* ... */ }

  @Task({ end: true })
  shipOrder() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Each @Task becomes a Lambda function, and the entire class becomes an AWS Step Functions state machine.

Storing invoices

import { Bucket } from '@lafken/bucket/main';

@Bucket({
  name: 'order-invoices',
  versioned: true,
  eventBridgeEnabled: true,
  lifeCycleRules: {
    'archived/': {
      expiration: { days: 365 },
      transitions: [
        { days: 30, storage: 'standard_ia' },
        { days: 90, storage: 'glacier' },
      ],
    },
  },
})
export class OrderInvoicesBucket {}
Enter fullscreen mode Exit fullscreen mode

Cleaning up automatically

import { Schedule, Cron } from '@lafken/schedule/main';

@Schedule()
export class OrderMaintenance {
  @Cron({ schedule: 'cron(0 3 * * ? *)' })
  cleanupExpiredOrders() {
    // Runs every day at 3:00 AM UTC
  }

  @Cron({ schedule: { hour: 0, minute: 0, weekDay: 'SUN' } })
  generateWeeklyReport() {
    // Runs every Sunday at midnight
  }
}
Enter fullscreen mode Exit fullscreen mode

Putting it all together

All these resources are grouped into a module and wired through createApp:

import { createApp, createModule } from '@lafken/main';
import { ApiResolver } from '@lafken/api/resolver';
import { EventRuleResolver } from '@lafken/event/resolver';
import { StateMachineResolver } from '@lafken/state-machine/resolver';
import { BucketResolver } from '@lafken/bucket/resolver';
import { DynamoResolver } from '@lafken/dynamo/resolver';
import { QueueResolver } from '@lafken/queue/resolver';
import { ScheduleResolver } from '@lafken/schedule/resolver';
// ... resource imports

const orderModule = createModule({
  name: 'order',
  resources: [OrderApi, OrderEvents, OrderFulfillment, PaymentQueue, OrderMaintenance],
});

createApp({
  name: 'order-app',
  resolvers: [
    new ApiResolver(),
    new EventRuleResolver({ busName: 'order-events' }),
    new StateMachineResolver(),
    new BucketResolver([OrderInvoicesBucket]),
    new DynamoResolver([OrderTable]),
    new QueueResolver(),
    new ScheduleResolver(),
  ],
  modules: [orderModule],
});
Enter fullscreen mode Exit fullscreen mode

That's the entire application. Seven AWS services, fully configured, from a single TypeScript codebase.

Why does Lafken exist?

Every time I started a new serverless project, I found myself repeating the same cycle:

  1. Write business logic in TypeScript
  2. Switch context to define infrastructure in YAML, HCL, or CDK constructs
  3. Debug deployment errors that had nothing to do with my application

The friction wasn't in any single step — it was in the constant context switching between writing application code and configuring infrastructure. I wanted to stay in TypeScript the entire time.

I wanted a framework where:

  1. The code IS the infrastructure — decorators describe what you need, the framework generates the rest
  2. You think in modules, not stacks — group related resources logically, not by AWS service type

How it works under the hood

Lafken doesn't hide Terraform — it generates it for you using cdk-terrain.

TypeScript Code (with decorators)
    ↓
Module (groups resources)
    ↓
App + Resolvers (processes decorators)
    ↓
Terraform Configuration (generated automatically)
Enter fullscreen mode Exit fullscreen mode
  • Decorators capture metadata about your classes and methods using TypeScript reflection
  • Modules group related resources into logical units
  • Resolvers read that metadata and generate real infrastructure via cdk-terrain
  • createApp() orchestrates everything and produces a Terraform plan ready to deploy
  • The output is standard Terraform. That means:

  • You can inspect the generated plan before deploying

  • You can integrate Lafken into an existing Terraform workflow

  • You keep all the benefits of Terraform state management

  • There's no vendor lock-in to a proprietary deployment engine

Getting started

npm create lafken@latest
Enter fullscreen mode Exit fullscreen mode

The create command walks you through a series of questions to scaffold your project with the right packages and configuration.

Documentation and repository

Lafken is under active development, and the best place to follow updates is the GitHub repository.

Repository & documentation: https://github.com/hero64/lafken

If you want to contribute, feedback and PRs are welcome.

You write TypeScript. Lafken generates Terraform. AWS does the rest.

Top comments (0)