My First Macro

Jorge at his desk next to his MacBook, stamping the @ symbol with a rubber stamp onto documents.

Table of contents


My Problem 🤔

In my project I have dozens of Fluent models, and there is a block of code that repeats in absolutely every single one of them:

public static let space: String? = "sales"

@ID() public var id: UUID?
public init() {}

@Timestamp(.createdAt, on: .create) public var createdAt: Date?
@Timestamp(.updatedAt, on: .update) public var updatedAt: Date?
@Timestamp(.deletedAt, on: .delete) public var deletedAt: Date?

Always the same block. In every model. No exceptions.

The namespace changes, but the structure is identical. With every new model, I copy and paste, adjust the namespace, and hope I don’t forget anything. With 30 models, that boilerplate becomes noise that makes it harder for me to read what actually matters: the model’s logic.

My solution in Swift for this kind of problem is a macro.


My Solution 🧩

A Swift macro can generate new members in a class or struct at compile time. Exactly what I need. The result I’m looking for is to write this:

@FluentModel(.sales)
public final class ProductModel: Model {
    public static let schema = "products"

    @Field(.name) public var name: String
}

And have the compiler automatically inject space, id, init(), and the three timestamps. Without writing them. Without maintaining them.

Package structure

Swift macros require two separate targets: the public interface (what I consume as a developer) and the plugin (the implementation that the compiler executes). In my case, both live in the same Macros package:

  • Macros — declares the macro and the DatabaseSpace enum
  • MacrosPlugin — implements the expansion with SwiftSyntax

DatabaseSpace — namespaces as types

Before defining the macro, I need to model the available namespaces. Instead of using loose strings, I decided that the DatabaseSpace enum should make them exhaustive and safe at compile time:

public enum DatabaseSpace: String, CaseIterable, Sendable {
    case sales, warehouse
}

This allows writing @FluentModel(.sales) instead of @FluentModel(“sales”). If the namespace doesn’t exist, the compiler tells you before anything runs.

The macro declaration

The public interface of the macro is surprisingly compact:

@attached(member, names: named(space), named(id), named(init), named(createdAt), named(updatedAt), named(deletedAt))
public macro FluentModel(_ space: DatabaseSpace? = nil) = #externalMacro(
    module: "MacrosPlugin",
    type: "FluentModelMacro"
)

The @attached(member, names:) attribute tells the compiler two things: that this macro adds members to the declaration where it’s applied, and exactly which names it will generate. Declaring the names is mandatory — Swift needs them to resolve the symbol tree before expanding the macro.

The implementation — FluentModelMacro

The implementation conforms to the MemberMacro protocol and returns an array of DeclSyntax — fragments of Swift code that the compiler inserts into the model:

import SwiftSyntax
import SwiftSyntaxMacros

public struct FluentModelMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        [
            spaceDecl(from: node),
            "@ID() public var id: UUID?",
            "public init() {}",
            "@Timestamp(.createdAt, on: .create) public var createdAt: Date?",
            "@Timestamp(.updatedAt, on: .update) public var updatedAt: Date?",
            "@Timestamp(.deletedAt, on: .delete) public var deletedAt: Date?",
        ]
    }

    private static func spaceDecl(from node: AttributeSyntax) -> DeclSyntax {
        let value = node.arguments?.as(LabeledExprListSyntax.self)?
            .first?.expression.as(MemberAccessExprSyntax.self)
            .map { "\"\($0.declName.baseName.text)\"" } ?? "nil"
        return "public static let space: String? = \(raw: value)"
    }
}

The expansion method directly returns the array of declarations, with no intermediate variables. Each element is a Swift literal that the compiler injects as-is into the model.

The key is spaceDecl, which encapsulates all the extraction and generation logic in a single method. It navigates the attribute’s syntax tree with SwiftSyntax using optional chaining: it accesses the arguments as LabeledExprListSyntax, takes the first expression, casts it to MemberAccessExprSyntax (because the argument is an enum case like .sales), and with .map converts it into a quoted string. If any step in the chain fails, the ?? operator returns “nil”. Finally, \(raw:) interpolates the value directly into the DeclSyntax.

Lastly, I need a CompilerPlugin to register the macro — it’s the entry point that the compiler loads to know which macros are available:

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct MacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        FluentModelMacro.self,
    ]
}

My Result 🎯

With the macro installed, each model is clean and noise-free:

@FluentModel(.sales)
public final class ProductModel: Model {
    public static let schema = "products"

    @Field(.name) public var name: String
    @OptionalField(.description) public var description: String?
}

The compiler expands @FluentModel(.sales) and automatically generates:

public static let space: String? = "sales"
@ID() public var id: UUID?
public init() {}
@Timestamp(.createdAt, on: .create) public var createdAt: Date?
@Timestamp(.updatedAt, on: .update) public var updatedAt: Date?
@Timestamp(.deletedAt, on: .delete) public var deletedAt: Date?

And if a model doesn’t belong to any namespace — like views — you simply omit the argument:

@FluentModel()
public final class OrderSummaryModel: Model {
    public static let schema = "order_summaries"
}

In that case, space is generated as nil and Fluent ignores the schema prefix.

The benefits in numbers: 32 models with the macro applied. 6 lines removed per model. Over 190 lines of boilerplate that no longer exist in the repository and will never need to be maintained again.

Keep coding, keep running 🏃‍♂️