Skip to content

Fundamentals

This guide teaches you the essential patterns for writing formae. You'll learn how to structure forma files, work with properties, reference resources, and avoid common mistakes.

Prerequisites: Complete the Quick Start to install formae and deploy your first forma. This guide builds on those concepts.

Getting started with formae

Every forma file begins with the same pattern:

amends "@formae/forma.pkl"
import "@formae/formae.pkl"
  • amends tells Pkl this is a forma document
  • import brings in the formae types you'll need

Import order best practices

Follow this order to avoid confusion:

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

// External resources
import "@aws/ec2/vpc.pkl"
import "@aws/s3/bucket.pkl"

// Your modules
import "./infrastructure/network.pkl"
import "./vars.pkl"

The building blocks

Every forma is composed of three primary building blocks: description, properties, and forma. Understanding these blocks is essential for writing effective infrastructure code.

Description block (optional)

The description block provides context to users about what the forma does and can optionally prompt for confirmation before applying changes:

description {
  text = """
  This forma creates a VPC with subnets in the \(properties.environment.value) environment.
  It will create the following resources:
  - VPC with CIDR block \(properties.vpcCidr.value)
  - Public and private subnets
  - Internet gateway and route tables
  """
  confirm = true  // Prompts user for confirmation before applying
}

String Templating: Use \(expression) to embed values directly in strings. This works anywhere in Pkl - resource names, descriptions, property values, etc. This is standard Pkl syntax.

The confirm flag is particularly useful for production infrastructure or destructive changes where you want an explicit approval step.

Properties block (optional)

Properties make your formae reusable and configurable. They become command-line flags automatically, making it easy for teams to customize infrastructure without editing code:

properties {
  environment = new formae.Prop {
    flag = "environment"
    default = "dev"
  }

  vpcCidr = new formae.Prop {
    flag = "vpc-cidr"
    default = "10.0.0.0/16"
  }

  enableNatGateway = new formae.Prop {
    flag = "enable-nat"
    default = "false"
  }
}

Access property values using .value:

cidrBlock = properties.vpcCidr.value

Note: Properties are automatically exposed as CLI flags. Run formae apply --help your-forma.pkl to see all available properties. Learn more in Properties.

Forma block

The forma block is where you define your infrastructure resources. This is the heart of your forma - it specifies what will be created, updated, or managed:

forma {
  // Define a stack to organize your resources
  new formae.Stack {
    label = "my-app-stack"
    description = "Infrastructure for my application"
  }

  // Define a target for where resources will be created
  new formae.Target {
    label = "aws-target"
    config = new aws.Config {
      region = properties.region.value
    }
  }

  // Define your infrastructure resources
  local myVpc = new vpc.Vpc {
    label = "main-vpc"
    cidrBlock = properties.vpcCidr.value
    tags {
      ["Name"] = "my-vpc-" + properties.environment.value
      ["Environment"] = properties.environment.value
    }
  }
  myVpc
}

Every forma needs a Stack to organize resources and a Target to specify where resources are deployed.

Simplicity by design: Unlike other IaC tools, formae doesn't need outputs or providers blocks. Resources automatically expose their properties through .res, and targets replace traditional provider configurations.

Understanding properties in depth

Properties are the key to making your formae flexible and reusable. They're not just variables - they're a powerful abstraction layer between infrastructure code and its configuration.

How properties work

When you define a property with a flag, it automatically becomes a command-line option:

properties {
  region = new formae.Prop {
    flag = "region"         // Becomes --region on CLI
    default = "us-east-1"   // Used if not specified
  }

  instanceCount = new formae.Prop {
    flag = "instances"
    default = "2"
  }
}

Use property values in your resources:

forma {
  new formae.Target {
    label = "aws-target"
    config = new aws.Config {
      region = properties.region.value  // Always use .value
    }
  }

  new s3.Bucket {
    label = "app-bucket"
    bucketName = "my-app-" + properties.region.value
  }
}

Override from the command line:

# Use defaults
formae apply --mode reconcile main.pkl

# Override region
formae apply --mode reconcile --region us-west-2 main.pkl

# Override multiple properties
formae apply --mode reconcile --region eu-west-1 --instances 5 main.pkl

Properties for platform engineering

Properties enable platform teams to create reusable formae that developers can customize without seeing or editing infrastructure code:

// Platform team creates this forma with configurable properties
properties {
  appName = new formae.Prop {
    flag = "app-name"
  }

  environment = new formae.Prop {
    flag = "env"
    default = "dev"
  }

  databaseSize = new formae.Prop {
    flag = "db-size"
    default = "small"
  }
}

// Infrastructure logic hidden from developers
forma {
  // Complex infrastructure setup based on simple properties
  // ...
}

Developers can deploy without understanding the infrastructure:

formae apply --mode reconcile --app-name myapp --env production --db-size large service.pkl

Tip: Properties are essential for abstracting infrastructure complexity in platform engineering scenarios. Learn more about this pattern in Properties.

The local pattern and resource references

Infrastructure resources often depend on each other. A subnet needs a VPC ID, a security group needs a VPC ID, and so on. The local pattern combined with .res handles these dependencies elegantly.

When to use local

Use local when you need to reference a resource's properties in other resources:

forma {
  // Declare a resource with local so we can reference it
  local myVpc = new vpc.Vpc {
    label = "main-vpc"
    cidrBlock = "10.0.0.0/16"
    enableDnsHostnames = true
    enableDnsSupport = true
  }
  myVpc // Don't forget to instantiate it, else it won't be rendered

  // Reference the VPC in multiple places using .res
  local publicSubnet = new subnet.Subnet {
    label = "public-subnet"
    vpcId = myVpc.res.vpcId  // Get VPC ID using .res
    cidrBlock = "10.0.1.0/24"
    availabilityZone = "us-east-1a"
  }
  publicSubnet

  new subnet.Subnet {
    label = "private-subnet"
    vpcId = myVpc.res.vpcId  // Reference same VPC
    cidrBlock = "10.0.2.0/24"
    availabilityZone = "us-east-1b"
  }

  new securitygroup.SecurityGroup {
    label = "app-sg"
    vpcId = myVpc.res.vpcId  // Reference same VPC again
    groupDescription = "Security group for application"
  }
}

Understanding .res (resolvables)

The .res suffix accesses a resource's properties. formae automatically handles:

  • Dependency ordering - Resources are created in the correct order
  • Property resolution - Values are injected when available
  • Waiting for creation - Dependencies wait for resources to be created

This eliminates the need to manually declare dependencies or worry about ordering.

Resources without dependencies

If a resource doesn't need to be referenced, don't use local:

forma {
  // No local needed - nothing references this bucket
  new bucket.Bucket {
    label = "logs-bucket"
    bucketName = "my-logs-bucket"
  }

  // No local needed - standalone resource
  new bucket.Bucket {
    label = "assets-bucket"
    bucketName = "my-assets-bucket"
  }
}

Rule of thumb: If you need to reference it, make it local and instantiate it. If you just need to create it, don't use local.

Deep dive: Learn more about how resolvables work and advanced patterns in Res.

Test your forma immediately

Before applying anything, check your work:

formae eval main.pkl

This shows you exactly what will be created, in JSON format. If you see something unexpected, fix it before applying.

Tip: Run formae eval after every significant change. It catches errors early and shows you the exact infrastructure that will be created.

Common mistakes

Incorrect property access. Properties have a .value suffix.

// Incorrect – property is not applied
bucketName = properties.environment

// Correct 🙂
bucketName = properties.environment.value

Failing to instantiate locals. Local variables must be instantiated to appear in the output.

// Incorrect - will not be rendered
forma {
  local myVpc = new vpc.Vpc {
    cidrBlock = "10.0.0.0/16"
  }
  // myVpc is declared but never instantiated - it won't be rendered!
}

// Correct 🙂
forma {
  local myVpc = new vpc.Vpc {
    cidrBlock = "10.0.0.0/16"
  }
  myVpc // Instantiate it so it gets rendered
}

Advanced: Working with nullable types

Pkl supports nullable types for optional configurations. This is useful when creating reusable modules that accept optional parameters.

Nullable type syntax

Indicate nullable types by adding a question mark after the type name:

// A nullable string that can be null or a string value
name: String? = "Default"

// A nullable dynamic object for optional configurations
config: Dynamic? = null

Null propagation

Use the null propagation operator (?.) to safely access properties without causing errors:

// Regular access - will error if myConfig is null
region = myConfig.region

// Null-safe access - returns null if myConfig is null
region = myConfig?.region

Default values with null coalescing

Use the null coalescing operator (??) to provide default values when a property might be null:

// Use "us-east-1" if myConfig?.region is null
region = myConfig?.region ?? "us-east-1"

// Useful for optional properties in module functions
function database(properties: Dynamic?): dbinstance.DBInstance = new dbinstance.DBInstance {
  engine = properties?.engine ?? "postgres"
  port = properties?.port ?? 5432
}

What's next

You now know how to write formae with proper structure, properties, and resource references. Next, learn how to organize complex infrastructure into reusable modules: