Design patterns

Component Patterns

Proven design patterns applied to Livt hardware components: Strategy, Proxy, Null Object, Pipeline, Repository, Observer, Iterator, and Command.

Livt's interfaces and component model let you apply design patterns from software engineering to hardware. The patterns solve real problems: vendor lock-in, untestable synthesis code, protocol stages that are hard to replace, and debug output that contaminates the synthesis path.

This page names eight patterns, shows each in Livt, and explains which hardware problem each one solves.

Strategy

Hardware problem: The same pipeline should work with different algorithms — different ciphers, filter coefficients, checksum functions, or protocol variants — without restructuring the surrounding components every time.

Pattern: Define an interface for the algorithm. The pipeline holds a field of that interface type and calls it. Construction time determines which algorithm is used.

livt
interface IChecksum
{
    fn Reset()
    fn Feed(data: byte)
    fn GetResult() int
}

component Crc16 : IChecksum
{
    override fn Reset()           { this.state = 0xFFFF }
    override fn Feed(data: byte)  { this.state = this.UpdateCrc(this.state, data) }
    override fn GetResult() int   { return this.state }
}

The component using the checksum does not know which implementation it received. Adding a third algorithm requires no changes to the pipeline.

Proxy

Hardware problem: A Livt component needs to use vendor-specific hardware — block RAM, a PLL, a FIFO primitive — but vendor primitives differ between families and boards. Tests need to run without the vendor toolchain present.

Pattern: Define a stable interface for the capability. The proxy wraps the vendor primitive behind that interface. In simulation, a small test double implements the same interface using plain Livt arrays.

livt
interface IRam
{
    fn Write(address: int, data: byte)
    fn Read(address: int) byte
}

component SimRam : IRam
{
    memory: byte[1024]

    override fn Write(address: int, data: byte)
    {
        this.memory[address] = data
    }

    override fn Read(address: int) byte
    {
        return this.memory[address]
    }
}

The component that needs storage depends only on IRam. During simulation a SimRam is injected. In synthesis a BlockRamProxy is used instead. No other code changes.

Null Object

Hardware problem: Debug output, performance counters, or trace logging should exist during development but must not be present in synthesis.

Pattern: Define an interface for the optional capability. The real implementation does the work. The null object implements the same interface with functions that do nothing.

livt
interface ILogger
{
    fn Log(message: string)
}

component UartLogger : ILogger
{
    override fn Log(message: string)
    {
        Simulation.Report(message)
    }
}

component NullLogger : ILogger
{
    override fn Log(message: string) { }
}

In development the component receives a UartLogger. In synthesis it receives a NullLogger. The synthesis tool sees empty function bodies and optimizes them away completely.

Pipeline

Hardware problem: Packet processing, DSP filter chains, and multi-stage transforms all have a common shape: each stage reads data, applies a transformation, and passes the result to the next stage.

Pattern: Define a common interface for a processing stage. Each stage implements the interface and forwards its output to the next stage, which it receives through its constructor.

livt
interface IByteTransform
{
    fn ProcessByte(data: byte) byte
}

component AsciiUpperTransform : IByteTransform
{
    next: IByteTransform

    new(next: IByteTransform)
    {
        this.next = next
    }

    override fn ProcessByte(data: byte) byte
    {
        var out: byte = data
        if (data >= 0x61 && data <= 0x7A)
        {
            out = (data - 0x20) as byte
        }
        return this.next.ProcessByte(out)
    }
}

Each stage can be reordered, replaced, or removed by changing only the constructor wiring.

Repository

Hardware problem: A component needs to store and retrieve data — ML model weights, ARP table entries, received frame bytes. The physical backing store may differ between deployment targets.

Pattern: Define an interface for data access. Each backing store implements the interface. The component that reads and writes depends only on the interface.

livt
interface IWeightStore
{
    fn GetWeight(layer: int, index: int) int
    fn SetWeight(layer: int, index: int, value: int)
}

component ConstantWeightStore : IWeightStore
{
    weights: int[4, 16]

    override fn GetWeight(layer: int, index: int) int
    {
        return this.weights[layer, index]
    }

    override fn SetWeight(layer: int, index: int, value: int)
    {
        // read-only; no-op
    }
}

A deployed model uses ConstantWeightStore — weights are synthesized into ROM. A trainable variant uses mutable storage. The inference component never changes.

Observer

Hardware problem: A protocol checker, traffic monitor, or coverage collector needs to observe signals without becoming part of the signal path.

Pattern: Define an event interface. The observed component holds an IObserver field and calls it when notable events occur. In synthesis the observer is a null object. In simulation it records or checks behavior.

livt
interface IFrameObserver
{
    fn OnFrameReceived(length: int)
    fn OnChecksumError()
}

component NullFrameObserver : IFrameObserver
{
    override fn OnFrameReceived(length: int) { }
    override fn OnChecksumError()            { }
}

component FrameStatisticsObserver : IFrameObserver
{
    public received: int
    public errors:   int

    override fn OnFrameReceived(length: int) { this.received++ }
    override fn OnChecksumError()            { this.errors++ }
}

In tests, the statistics observer is injected and assertions check observer.received and observer.errors.

Iterator

Hardware problem: A component needs to walk over a collection of elements without the traversal logic leaking into the component that owns the data.

Pattern: Separate the cursor from the data. The cursor component implements an iterator interface with three operations: Init, HasNext, and Next.

livt
interface IByteIterator
{
    fn Init()
    fn HasNext() bool
    fn Next() byte
}

component ByteBufferIterator : IByteIterator
{
    buffer: byte[64]
    length: int
    pos:    int

    public fn Load(data: byte[64], len: int)
    {
        this.buffer = data
        this.length = len
    }

    override fn Init()         { this.pos = 0 }
    override fn HasNext() bool { return this.pos < this.length }
    override fn Next() byte
    {
        var b: byte = this.buffer[this.pos]
        this.pos++
        return b
    }
}

With foreach, the caller can iterate directly over the cursor:

livt
foreach (var b in this.cursor)
{
    acc = acc + (b as int)
}

The compiler desugars this to Init() + while (HasNext()) + Next().

Command

Hardware problem: An application component needs to execute different operations depending on input. If each operation is a case inside one large if/elif chain, the component becomes hard to test and hard to extend.

Pattern: Encapsulate each operation as a component that implements a common interface. New operations are added by writing a new component, not by editing existing logic.

livt
interface ICommand
{
    fn Execute()
}

component ResetCountersCommand : ICommand
{
    stats: PacketStatistics

    new(stats: PacketStatistics)
    {
        this.stats = stats
    }

    override fn Execute()
    {
        this.stats.Reset()
    }
}

Testing is direct: construct the command, call Execute(), and assert the expected side effect. The dispatcher does not need to be involved.

Summary

Pattern Hardware problem solved
Strategy Swap algorithms without restructuring the pipeline
Proxy Isolate vendor primitives behind a stable interface
Null Object Remove debug or monitoring code from the synthesis path cleanly
Pipeline Connect processing stages that can be reordered or replaced independently
Repository Decouple data access from the physical backing store
Observer Non-intrusive monitoring and protocol checking
Iterator Traversal without coupling to the data structure
Command Testable, replaceable operations

These patterns work because Livt interfaces provide the mechanism they need: components can depend on abstractions, and different implementations can be substituted at construction time.