Skip to content

Modular infrastructure

As your infrastructure grows, organizing code into modules becomes essential. This guide shows you how to structure formae into reusable components, share configuration across files, and build composable infrastructure patterns.

Prerequisites: Understand the basics from Fundamentals before diving into modular patterns.

Why modular infrastructure?

Breaking infrastructure into modules provides several benefits:

  • Reusability - Write once, use in multiple formae
  • Team ownership - Different teams can own different modules
  • Maintainability - Smaller files are easier to understand and modify
  • Testing - Test modules independently
  • Composition - Combine modules to build complex infrastructure

Shared configuration patterns

The vars.pkl pattern

Most formae projects use a shared vars.pkl file for common configurations like stack, target, and properties:

// vars.pkl
import "@formae/formae.pkl"
import "@aws/aws.pkl"

properties {
    name = new formae.Prop {
        flag = "name"
        default = "my-project"
    }

    region = new formae.Prop {
        flag = "region"
        default = "us-east-2"
    }
}

// Shared across all your formas
stack: formae.Stack = new {
    label = properties.name.value + "-stack"
    description = "My project infrastructure"
}

target: formae.Target = new formae.Target {
    label = "default-aws-target"
    config = new aws.Config {
        region = properties.region.value
    }
}

Then import and use in your formas:

// main.pkl
amends "@formae/forma.pkl"
import "@formae/formae.pkl"
import "@aws/s3/bucket.pkl"
import "./vars.pkl"

forma {
    vars.stack    // Use the shared stack
    vars.target   // Use the shared target

    // Your resources here
    new bucket.Bucket {
        label = "my-bucket"
        bucketName = "my-app-" + properties.name.value
    }
}

This pattern keeps your stack and target configuration in one place, making it easy to apply consistent settings across multiple formae.

Tip: Using a shared vars.pkl is especially helpful when you have multiple formae that deploy to the same stack or use the same AWS account/region configuration.

Organizing infrastructure code

formae supports two approaches for creating reusable infrastructure. Choose the one that fits your use case.

Class-based modules

Classes encapsulate related resources and expose a clean API. Use classes when:

  • Building multi-component infrastructure (VPC + subnets + security groups)
  • Passing resources between components (VPC → Database)
  • Hiding implementation details from consumers

Function-based modules

Functions create resources with a clear flow. Use functions when:

  • Building straightforward infrastructure without complex composition
  • Creating utility functions for repetitive patterns

Building with classes

Classes are Pkl's native way to organize code. They're perfect for infrastructure because they encapsulate related resources and expose a clean API.

A simple networking class

// infrastructure/networking.pkl
import "@aws/ec2/vpc.pkl"
import "@aws/ec2/subnet.pkl"
import "@aws/ec2/securitygroup.pkl"

class Networking {
  name: String
  vpcCidr: String
  region: String

  hidden vpc: vpc.VPC = new {
    label = "\(name)-vpc"
    cidrBlock = vpcCidr
    enableDnsHostnames = true
    enableDnsSupport = true
    tags {
      new { key = "Name"; value = "\(name)-vpc" }
    }
  }

  hidden publicSubnet: subnet.Subnet = new {
    label = "\(name)-public-subnet"
    vpcId = vpc.res.id
    cidrBlock = "10.0.1.0/24"
    availabilityZone = "\(region)a"
    tags {
      new { key = "Name"; value = "\(name)-public-subnet" }
    }
  }

  hidden appSecurityGroup: securitygroup.SecurityGroup = new {
    label = "\(name)-app-sg"
    vpcId = vpc.res.id
    groupDescription = "Application security group"
    tags {
      new { key = "Name"; value = "\(name)-app-sg" }
    }
  }

  resources: Listing = new {
    vpc
    publicSubnet
    appSecurityGroup
  }
}

Use it in your forma:

// main.pkl
amends "@formae/forma.pkl"
import "./infrastructure/networking.pkl"
import "./vars.pkl"

local network = new networking.Networking {
  name = properties.name.value
  vpcCidr = properties.vpcCidr.value
  region = properties.region.value
}

forma {
  vars.stack
  vars.target
  ...network.resources
}

Composing classes

Pass resources between classes to build layered infrastructure:

// VPC class creates networking resources
class VpcResources {
  team: String

  hidden vpc: vpc.VPC = new { ... }
  hidden subnet1: subnet.Subnet = new { ... }
  hidden subnet2: subnet.Subnet = new { ... }

  resources: Listing = new { vpc; subnet1; subnet2 }
}

// Database class receives VPC resources
class DatabaseResources {
  team: String
  vpc: vpc.VPC              // Pass in VPC from another class
  subnet1: subnet.Subnet
  subnet2: subnet.Subnet

  hidden dbSubnetGroup: dbsubnetgroup.DBSubnetGroup = new {
    subnetIds { subnet1.res.subnetId; subnet2.res.subnetId }
  }

  resources: Listing = new { dbSubnetGroup; /* ... */ }
}

// Use them together
local network = new vpc_resources.VpcResources { team = "platform" }
local database = new database_resources.DatabaseResources {
  team = "platform"
  vpc = network.vpc
  subnet1 = network.subnet1
  subnet2 = network.subnet2
}

forma {
  ...network.resources    // Spread operator expands the Listing
  ...database.resources
}

Spread operator: Use ... to expand a Listing into individual resources. Works for both classes and functions.

The outer keyword

When nesting classes, use outer to reference parent properties:

class MLPlatform {
  name: String
  region: String
  accountId: String

  hidden networking: networking.Networking = new {
    name = outer.name        // Reference parent's name
    region = outer.region    // Reference parent's region
  }

  hidden storage: storage.Storage = new {
    name = outer.name
    region = outer.region
    accountId = outer.accountId
  }
}

Function patterns

Functions work well for resource creation that follows a clear flow:

// Simple resource creation
function vpc(properties: Dynamic?): vpc.VPC = new vpc.VPC {
  label = "main-vpc"
  cidrBlock = properties.vpcCidr.value
  enableDnsHostnames = true
}

// Utility functions
function subnetCidr(base: String, index: Int): String = 
  base.replaceAll("/16", "/24").replaceLast(".0", ".\(index)")

function tags(name: String, env: String): Listing = new Listing {
  new { key = "Name"; value = name }
  new { key = "Environment"; value = env }
  new { key = "ManagedBy"; value = "formae" }
}

Imports and references

Import types

// Formae framework
amends "@formae/forma.pkl"
import "@formae/formae.pkl"

// AWS resource types
import "@aws/ec2/vpc.pkl"
import "@aws/s3/bucket.pkl"

// Your modules (relative paths)
import "./infrastructure/networking.pkl"
import "./vars.pkl"
import "../shared/storage.pkl"

Referencing resources

Use .res to access resource attributes:

new subnet.Subnet {
  vpcId = myVpc.res.id          // Reference resource ID
  routeTableId = myRT.res.id
}

new policy.Policy {
  policyDocument {
    ["Resource"] = myBucket.res.arn  // Reference resource ARN
  }
}

Note: The .res mechanism automatically handles dependency ordering. Learn more in Res.

What's next

You now know how to organize infrastructure using both classes and functions. Choose the approach that matches your use case. Next steps: