Builder Pattern in UVM: From Telescoping Constructors to Fluent Interfaces
Every AXI testbench has this pattern somewhere: a sequence that builds transactions field by field — setting the address, picking a burst type, choosing the size, configuring cache attributes, setting protection bits. For a simple read, that's five lines. For an exclusive wrapped burst with specific cache attributes? That's fifteen lines of field assignments where a single wrong value — burst_len = 4 when you meant 4 beats (which is burst_len = 3) — causes a protocol violation that won't surface until deep in simulation.
The Builder pattern separates what you want (a cacheable 4-beat write burst to 0x1000) from how it gets constructed (setting AxBURST, AxSIZE, AxLEN, AxCACHE correctly). You get readable sequences, early validation, and construction logic that lives in one place.
- The Problem: Telescoping Constructors
- Gang of Four: Builder Patterns
- AXI Transaction Builder
- Builder + Randomization
- Builder vs Alternatives
- Advanced: Directors for Test Scenarios
- Quick Reference
The Problem: Telescoping Constructors
Here's a typical AXI transaction class. Nothing exotic — just the standard fields you'd find in any AXI VIP:
A Standard AXI Transaction
typedef enum bit { AXI_READ = 0, AXI_WRITE = 1 } axi_op_e;
typedef enum bit [1:0] { FIXED = 2'b00, INCR = 2'b01, WRAP = 2'b10 } axi_burst_e;
typedef enum bit [1:0] { OKAY = 2'b00, EXOKAY = 2'b01, SLVERR = 2'b10, DECERR = 2'b11 } axi_resp_e;
class axi_txn extends uvm_sequence_item;
rand axi_op_e op;
rand bit [31:0] addr;
rand bit [31:0] data[];
rand axi_burst_e burst_type;
rand bit [7:0] burst_len; // Beats - 1
rand bit [2:0] burst_size; // Bytes/beat = 2^burst_size
rand bit [3:0] cache; // AxCACHE
rand bit [2:0] prot; // AxPROT
rand bit [3:0] qos; // AxQOS
rand bit lock; // Exclusive access
rand bit [7:0] id; // Transaction ID
rand axi_resp_e exp_resp; // Expected response
`uvm_object_utils(axi_txn)
function new(string name = "axi_txn");
super.new(name);
endfunction
endclass
14 fields. Now build a specific transaction: a 4-beat incrementing write burst to address 0x1000 with cacheable attributes.
Approach 1: Direct Field Assignment
axi_txn txn = axi_txn::type_id::create("txn");
txn.op = AXI_WRITE;
txn.addr = 32'h0000_1000;
txn.data = '{32'hAAAA, 32'hBBBB, 32'hCCCC, 32'hDDDD};
txn.burst_type = INCR;
txn.burst_len = 3; // 4 beats, but the field says "3"?
txn.burst_size = 3'b010; // What's 010 — 4 bytes? 2 bytes?
txn.cache = 4'b0011; // Which bits are cacheable again?
txn.prot = 3'b000;
txn.qos = 0;
txn.lock = 0;
txn.id = 1;
txn.exp_resp = OKAY;
Thirteen lines. The intent — "4-beat cacheable write to 0x1000" — is buried under protocol encoding. What does burst_len = 3 mean? Why is burst_size = 3'b010? What cache bits does 4'b0011 set? A reviewer has to decode AXI encoding to understand the test.
Approach 2: Inline Constraints
if (!txn.randomize() with {
op == AXI_WRITE;
addr == 32'h0000_1000;
burst_type == INCR;
burst_len == 3;
burst_size == 3'b010;
cache == 4'b0011;
prot == 3'b000;
lock == 0;
data.size() == 4;
}) begin
`uvm_fatal("RAND", "Randomization failed")
end
Same magic numbers, now with constraint-solver overhead for what are essentially fixed values.
Approach 3: Helper Functions
function axi_txn create_write_burst(bit [31:0] addr, int beats);
function axi_txn create_read_burst(bit [31:0] addr, int beats);
function axi_txn create_exclusive_write(bit [31:0] addr, bit [31:0] data);
function axi_txn create_cacheable_write_burst(bit [31:0] addr, int beats, bit [3:0] cache);
function axi_txn create_exclusive_cacheable_wrapped_write(...);
This is the Telescoping Constructor anti-pattern, named by Joshua Bloch in Effective Java. Each combination of options demands a new function. With N optional features, you need up to 2N helpers. Burst type × cache policy × lock mode × protection = combinatorial explosion.
All three approaches share the same root cause: construction knowledge is scattered. The rules of AXI — how burst_len relates to beat count, which cache bits mean what, when lock requires specific alignment — live nowhere specific. They're repeated in every sequence, every test, every review comment that says "this burst_len looks wrong."
Now compare:
axi_txn txn = axi_builder::create()
.write(32'h1000)
.data('{32'hAAAA, 32'hBBBB, 32'hCCCC, 32'hDDDD})
.incr_burst(4)
.cacheable()
.build();
Five lines. No magic numbers. The intent is the code.
Gang of Four: Builder Patterns
The Gang of Four defines the Builder pattern as:
Separate the construction of a complex object from its representation so that the same construction process can create different representations.
The pattern has three forms. Each solves a different aspect of the construction problem.
1. Classic Builder (GoF)
The original pattern separates what to build (Director) from how to build it (Builder):
classDiagram
class Director {
-builder : Builder
+construct()
}
class Builder {
<<abstract>>
+setAddress()*
+setBurst()*
+setCache()*
+getResult()* : Product
}
class ConcreteBuilder {
-product : Product
+setAddress()
+setBurst()
+setCache()
+getResult() : Product
}
class Product
Director o-- Builder
Builder <|-- ConcreteBuilder
ConcreteBuilder ..> Product : creates
The Director defines construction order. The Builder defines construction mechanics. Neither knows about the other's internals.
2. Fluent Builder (Bloch Variant)
Joshua Bloch adapted the pattern in Effective Java for objects with many optional parameters. Every setter returns this, enabling method chaining:
classDiagram
class AxiBuilder {
-addr : bit[31:0]
-burst_type : axi_burst_e
-cache : bit[3:0]
+create()$ : AxiBuilder
+write(addr) : AxiBuilder
+incr_burst(beats) : AxiBuilder
+cacheable() : AxiBuilder
+build() : axi_txn
}
class axi_txn {
+op
+addr
+burst_type
+cache
}
AxiBuilder ..> axi_txn : creates
Martin Fowler calls this a fluent interface — code that reads like a declaration:
txn = axi_builder::create().write(32'h1000).incr_burst(4).cacheable().build();
This is the form you'll use most in testbenches. The Director form becomes useful when you need reusable construction recipes — we'll cover that in the advanced section.
3. Director Pattern
A Director encapsulates a construction sequence. Different Directors use the same Builder to produce different results:
sequenceDiagram
participant Test
participant Director
participant Builder
participant Txn as axi_txn
Test->>Director: construct()
Director->>Builder: write(addr)
Director->>Builder: incr_burst(4)
Director->>Builder: cacheable()
Director->>Builder: build()
Builder-->>Director: Txn
Director-->>Test: Txn
This matters at scale: a memory_test_director and an exclusive_access_director both use the same axi_builder but produce fundamentally different transaction sequences.
SOLID Principles
The Builder pattern aligns with four SOLID principles — this is why it scales in verification environments:
| Principle | How Builder Applies |
|---|---|
| Single Responsibility | axi_txn holds data. The Builder handles construction. Neither does the other's job. |
| Open-Closed | New transaction variants = new Builder chains or Directors. No modifications to existing classes. |
| Liskov Substitution | Any Director works with any Builder that implements the interface. |
| Dependency Inversion | Sequences depend on the Builder abstraction, not on AXI field encoding. |
AXI Transaction Builder
Here's a complete axi_builder that produces axi_txn sequence items. Every setter returns this for chaining. The build() method validates AXI protocol rules before creating the product.
Builder Skeleton
class axi_builder;
// Internal state
protected axi_op_e m_op;
protected bit [31:0] m_addr;
protected bit [31:0] m_data[$];
protected axi_burst_e m_burst_type;
protected int unsigned m_beats;
protected bit [2:0] m_burst_size;
protected bit [3:0] m_cache;
protected bit [2:0] m_prot;
protected bit [3:0] m_qos;
protected bit m_lock;
protected bit [7:0] m_id;
protected axi_resp_e m_exp_resp;
// Private constructor with sensible defaults
local function new();
m_op = AXI_READ;
m_burst_type = INCR;
m_beats = 1;
m_burst_size = 3'b010; // 4 bytes (32-bit bus)
m_exp_resp = OKAY;
endfunction
// Entry point
static function axi_builder create();
axi_builder b = new();
return b;
endfunction
The local constructor prevents external new() calls. All construction goes through create() — this lets you add builder pooling or pre-configured templates later without breaking callers.
Notice m_beats instead of m_burst_len. Internally, the Builder thinks in beats (human-readable: "4 beats") and converts to AXI encoding (burst_len = 3) in apply(). This is the Builder's core value: encode domain knowledge once, use it everywhere.
Fluent Setters
Setters come in two levels: field-level for full control, and semantic for readability.
Operation and Address
// --- Operation ---
function axi_builder read(bit [31:0] addr);
m_op = AXI_READ;
m_addr = addr;
return this;
endfunction
function axi_builder write(bit [31:0] addr);
m_op = AXI_WRITE;
m_addr = addr;
return this;
endfunction
function axi_builder data(bit [31:0] d[$]);
m_data = d;
return this;
endfunction
Burst Configuration
// --- Burst: Semantic setters ---
// User says "4 beats", Builder handles burst_len = 3
function axi_builder incr_burst(int unsigned beats);
m_burst_type = INCR;
m_beats = beats;
return this;
endfunction
function axi_builder wrap_burst(int unsigned beats);
m_burst_type = WRAP;
m_beats = beats;
return this;
endfunction
function axi_builder fixed_burst(int unsigned beats);
m_burst_type = FIXED;
m_beats = beats;
return this;
endfunction
// Single-beat transfer (default)
function axi_builder single();
m_burst_type = INCR;
m_beats = 1;
return this;
endfunction
// Field-level: override bytes per beat
function axi_builder with_size(int unsigned bytes_per_beat);
case (bytes_per_beat)
1: m_burst_size = 3'b000;
2: m_burst_size = 3'b001;
4: m_burst_size = 3'b010;
8: m_burst_size = 3'b011;
16: m_burst_size = 3'b100;
32: m_burst_size = 3'b101;
64: m_burst_size = 3'b110;
128: m_burst_size = 3'b111;
endcase
return this;
endfunction
The burst setters hide AXI's burst_len = beats - 1 encoding. No more off-by-one bugs. incr_burst(4) means 4 beats — the Builder converts to burst_len = 3 internally.
Cache, Protection, and QoS
// --- Cache: Semantic presets ---
// AxCACHE[0]=Bufferable, [1]=Cacheable, [2]=Read-Alloc, [3]=Write-Alloc
function axi_builder bufferable();
m_cache[0] = 1;
return this;
endfunction
function axi_builder cacheable();
m_cache[1] = 1;
m_cache[0] = 1; // Cacheable implies bufferable
return this;
endfunction
function axi_builder write_back();
m_cache = 4'b1111; // Full caching: WA + RA + C + B
return this;
endfunction
// Field-level
function axi_builder with_cache(bit [3:0] cache);
m_cache = cache;
return this;
endfunction
// --- Protection ---
// AxPROT[0]=Privileged, [1]=Non-secure, [2]=Instruction
function axi_builder privileged();
m_prot[0] = 1;
return this;
endfunction
function axi_builder non_secure();
m_prot[1] = 1;
return this;
endfunction
function axi_builder instruction();
m_prot[2] = 1;
return this;
endfunction
// Field-level
function axi_builder with_prot(bit [2:0] prot);
m_prot = prot;
return this;
endfunction
// --- Other ---
function axi_builder exclusive();
m_lock = 1;
return this;
endfunction
function axi_builder with_qos(bit [3:0] qos);
m_qos = qos;
return this;
endfunction
function axi_builder with_id(bit [7:0] id);
m_id = id;
return this;
endfunction
function axi_builder expect_slverr();
m_exp_resp = SLVERR;
return this;
endfunction
function axi_builder expect_decerr();
m_exp_resp = DECERR;
return this;
endfunction
Two levels of abstraction. cacheable() and privileged() encode AXI semantics — no one needs to remember that AxCACHE[1] is the cacheable bit. with_cache() and with_prot() give full control when you need to test specific bit patterns.
Validation and Build
The build() method is the only way to get a product. It validates AXI protocol rules at construction time — catching errors before simulation, not 100K cycles in when a scoreboard mismatch surfaces.
// --- Terminal Operation ---
function axi_txn build();
validate();
return apply();
endfunction
protected function void validate();
// WRAP burst: length must be 2, 4, 8, or 16
if (m_burst_type == WRAP) begin
if (!(m_beats inside {2, 4, 8, 16})) begin
`uvm_fatal("AXI_BUILD", $sformatf(
"WRAP burst length must be 2, 4, 8, or 16. Got %0d.", m_beats))
end
end
// INCR burst: cannot cross 4KB boundary
if (m_burst_type == INCR && m_beats > 1) begin
int unsigned bytes_per_beat = 1 << m_burst_size;
int unsigned total_bytes = m_beats * bytes_per_beat;
int unsigned start_4kb = m_addr[31:12];
int unsigned end_4kb = (m_addr + total_bytes - 1) >> 12;
if (start_4kb != end_4kb) begin
`uvm_warning("AXI_BUILD", $sformatf(
"INCR burst [0x%08h + %0d bytes] crosses 4KB boundary.",
m_addr, total_bytes))
end
end
// Write data must match beat count
if (m_op == AXI_WRITE && m_data.size() > 0 && m_data.size() != m_beats) begin
`uvm_fatal("AXI_BUILD", $sformatf(
"Write data size (%0d) doesn't match beat count (%0d).",
m_data.size(), m_beats))
end
// Exclusive requires aligned address
if (m_lock) begin
int unsigned align = (1 << m_burst_size) * m_beats;
if (m_addr % align != 0) begin
`uvm_warning("AXI_BUILD", $sformatf(
"Exclusive access at 0x%08h is not %0d-byte aligned.",
m_addr, align))
end
end
endfunction
protected function axi_txn apply();
axi_txn t = axi_txn::type_id::create("txn");
t.op = m_op;
t.addr = m_addr;
t.burst_type = m_burst_type;
t.burst_len = m_beats - 1; // AXI encoding: beats - 1
t.burst_size = m_burst_size;
t.cache = m_cache;
t.prot = m_prot;
t.qos = m_qos;
t.lock = m_lock;
t.id = m_id;
t.exp_resp = m_exp_resp;
if (m_op == AXI_WRITE && m_data.size() > 0) begin
t.data = new[m_data.size()];
foreach (m_data[i]) begin
t.data[i] = m_data[i];
end
end
else if (m_op == AXI_WRITE) begin
t.data = new[m_beats];
end
return t;
endfunction
endclass
validate() catches three classes of errors:
- Protocol violations — WRAP burst with invalid length
- Boundary issues — INCR burst crossing 4KB (AXI spec requires this not to happen)
- Consistency errors — Write data array size doesn't match burst length
apply() handles the translation from human-friendly values to AXI encoding. The critical line: t.burst_len = m_beats - 1. This off-by-one conversion lives in one place, not scattered across every sequence.
Note that build() creates the transaction via axi_txn::type_id::create() — the UVM factory. If a test overrides axi_txn with axi_txn_with_coverage, the Builder automatically produces the overridden type. Builder and Factory work together.
Usage in Sequences
With the Builder, sequences express test intent:
class axi_directed_seq extends uvm_sequence #(axi_txn);
`uvm_object_utils(axi_directed_seq)
function new(string name = "axi_directed_seq");
super.new(name);
endfunction
task body();
axi_txn txn;
// 4-beat cacheable write to 0x1000
txn = axi_builder::create()
.write(32'h0000_1000)
.data('{32'hAAAA, 32'hBBBB, 32'hCCCC, 32'hDDDD})
.incr_burst(4)
.cacheable()
.with_id(1)
.build();
`uvm_send(txn)
// Read back the same region
txn = axi_builder::create()
.read(32'h0000_1000)
.incr_burst(4)
.cacheable()
.with_id(2)
.build();
`uvm_send(txn)
// Exclusive write — atomic operation
txn = axi_builder::create()
.write(32'h0000_2000)
.data('{32'hDEAD_BEEF})
.exclusive()
.build();
`uvm_send(txn)
// Wrapped burst to device register space
txn = axi_builder::create()
.read(32'h4000_0000)
.wrap_burst(4)
.non_secure()
.expect_slverr()
.build();
`uvm_send(txn)
endtask
endclass
Each transaction reads as a statement of intent. A reviewer understands the test plan — write, read-back, exclusive atomic, device access with expected error — without decoding AXI field encodings.
Builder + Randomization
The Builder doesn't replace constrained random — it complements it. Use the Builder to set a transaction's structural identity, then let the constraint solver vary the rest:
class axi_random_burst_seq extends uvm_sequence #(axi_txn);
`uvm_object_utils(axi_random_burst_seq)
function new(string name = "axi_random_burst_seq");
super.new(name);
endfunction
task body();
axi_txn txn;
repeat (100) begin
// Builder: deterministic structure
txn = axi_builder::create()
.write(32'h0000_0000)
.incr_burst(4)
.cacheable()
.build();
// Randomize: vary address and data within bounds
if (!txn.randomize() with {
addr inside {[32'h0000_0000 : 32'h0000_FFFF]};
foreach (data[i]) data[i] != 0;
}) begin
`uvm_fatal("RAND", "Randomization failed")
end
`uvm_send(txn)
end
endtask
endclass
The Builder provides the deterministic skeleton — "this is a 4-beat cacheable INCR write." Randomization provides variation within bounds — different addresses, different data patterns. You get both test readability and coverage diversity.
Builder vs Alternatives
The Builder isn't always the right tool. Use the simplest approach that works:
| Approach | Best For | Breaks Down When |
|---|---|---|
randomize() with {} | Pure random, few constraints | Constraint blocks exceed ~10 lines; cross-field rules need validation |
| Field assignment | Quick debug, 3-4 fields | Magic numbers accumulate; protocol rules aren't enforced |
| Helper functions | 2-3 fixed transaction types | Combinations multiply past ~5 functions |
uvm_config_db | Environment-level config | Per-transaction construction — wrong granularity |
| Builder | Complex objects, optional fields, protocol rules | Simple objects with <5 fields — overhead not justified |
The same transaction, four ways:
// 1. Field assignment — magic numbers everywhere
txn.op = AXI_WRITE; txn.addr = 32'h1000;
txn.burst_type = INCR; txn.burst_len = 3;
txn.burst_size = 3'b010; txn.cache = 4'b0011;
// 2. Inline constraints — solver overhead for constants
txn.randomize() with {
op == AXI_WRITE; addr == 32'h1000;
burst_type == INCR; burst_len == 3;
burst_size == 3'b010; cache == 4'b0011;
};
// 3. Helper — what's the 5th argument?
txn = create_write_burst(32'h1000, 4, 3'b010, 4'b0011, 0);
// 4. Builder — self-documenting
txn = axi_builder::create()
.write(32'h1000)
.incr_burst(4)
.cacheable()
.build();
Advanced: Directors for Test Scenarios
The fluent Builder is great for individual transactions. But verification operates at the scenario level — sequences of transactions that exercise specific DUT behavior. The Director pattern wraps reusable construction recipes around the Builder.
Base Director
virtual class axi_director;
// Template Method: subclasses fill in the steps
pure virtual function void get_txns(ref axi_txn txns[$]);
endclass
Memory Test Director
Generates write-then-read pairs across an address range — the bread-and-butter of memory subsystem verification:
class memory_test_director extends axi_director;
protected bit [31:0] m_base_addr;
protected int unsigned m_region_size;
protected int unsigned m_burst_beats;
function new(bit [31:0] base_addr, int unsigned region_size = 256,
int unsigned burst_beats = 4);
m_base_addr = base_addr;
m_region_size = region_size;
m_burst_beats = burst_beats;
endfunction
virtual function void get_txns(ref axi_txn txns[$]);
int unsigned stride = m_burst_beats * 4;
for (int unsigned offset = 0; offset < m_region_size; offset += stride) begin
// Write burst
txns.push_back(
axi_builder::create()
.write(m_base_addr + offset)
.incr_burst(m_burst_beats)
.cacheable()
.with_id(offset[7:0])
.build()
);
// Read-back
txns.push_back(
axi_builder::create()
.read(m_base_addr + offset)
.incr_burst(m_burst_beats)
.cacheable()
.with_id(offset[7:0])
.build()
);
end
endfunction
endclass
Exclusive Access Director
Generates exclusive read → modify → exclusive write pairs for atomic operation testing:
class exclusive_access_director extends axi_director;
protected bit [31:0] m_addrs[$];
function new(bit [31:0] addrs[$]);
m_addrs = addrs;
endfunction
virtual function void get_txns(ref axi_txn txns[$]);
foreach (m_addrs[i]) begin
// Exclusive read (load-linked)
txns.push_back(
axi_builder::create()
.read(m_addrs[i])
.exclusive()
.with_id(i[7:0])
.build()
);
// Exclusive write (store-conditional)
txns.push_back(
axi_builder::create()
.write(m_addrs[i])
.data('{32'hFFFF_FFFF})
.exclusive()
.with_id(i[7:0])
.build()
);
end
endfunction
endclass
Composing Directors in a Virtual Sequence
class axi_regression_vseq extends uvm_sequence #(axi_txn);
`uvm_object_utils(axi_regression_vseq)
function new(string name = "axi_regression_vseq");
super.new(name);
endfunction
task body();
axi_txn txns[$];
// Phase 1: Memory sweep
begin
memory_test_director dir = new(
.base_addr(32'h0000_0000),
.region_size(1024),
.burst_beats(8)
);
dir.get_txns(txns);
send_all(txns);
txns.delete();
end
// Phase 2: Atomic operations
begin
exclusive_access_director dir = new(
'{32'h0001_0000, 32'h0001_0010, 32'h0001_0020}
);
dir.get_txns(txns);
send_all(txns);
end
endtask
task send_all(ref axi_txn txns[$]);
foreach (txns[i]) begin
start_item(txns[i]);
finish_item(txns[i]);
end
endtask
endclass
The virtual sequence reads like a test plan: memory sweep, then atomic operations. Directors are reusable across tests. New scenarios = new Directors, not new copy-pasted sequences.
Quick Reference
| Operation | Code |
|---|---|
| Create builder | axi_builder::create() |
| Read/Write | .read(addr), .write(addr) |
| Burst | .incr_burst(beats), .wrap_burst(beats), .fixed_burst(beats) |
| Cache | .cacheable(), .bufferable(), .write_back(), .with_cache(val) |
| Protection | .privileged(), .non_secure(), .instruction(), .with_prot(val) |
| Exclusive | .exclusive() |
| Expected response | .expect_slverr(), .expect_decerr() |
| Build | .build() |
When to Use Builder
| Situation | Use |
|---|---|
| Simple read/write, few fields | randomize() with {} or field assignment |
| Fixed configs used everywhere | Helper functions (2-3 max) |
| Complex transactions with protocol rules | Builder |
| Reusable multi-transaction scenarios | Builder + Director |
Common Mistakes
| Mistake | Fix |
|---|---|
| Using Builder for 3-field transactions | Direct assignment is fine for simple cases |
Forgetting .build() | Builder is not the product — always call .build() |
Skipping validation in build() | Validation is Builder's main advantage. Always validate. |
Setting burst_len directly | Use .incr_burst(beats) — let the Builder handle encoding |
| Director that hard-codes protocol fields | Keep AXI encoding in the Builder, keep scenario logic in the Director |
Builder Template
Copy this as a starting point for any protocol:
class my_txn_builder;
protected bit [31:0] m_addr;
protected bit [31:0] m_data;
local function new();
endfunction
static function my_txn_builder create();
my_txn_builder b = new();
return b;
endfunction
function my_txn_builder with_addr(bit [31:0] addr);
m_addr = addr;
return this;
endfunction
function my_txn build();
validate();
my_txn t = my_txn::type_id::create("t");
t.addr = m_addr;
t.data = m_data;
return t;
endfunction
protected function void validate();
// Protocol rules here
endfunction
endclass
Previous: Singleton Pattern — uvm_root, uvm_config_db, and global resources
Next: Prototype Pattern — clone() methods, transaction copying, and do_copy()
Comments (0)
Leave a Comment