
Introduction
Go is known for its simplicity and clarity, but when it comes to managing state or creating reactive data flows, developers often have to write a lot of boilerplate. With the introduction of generics in Go 1.18, new patterns are emerging that make it easier to express reusable, type-safe abstractions. One such pattern is the binding properties pattern — a way to encapsulate values that can be observed, updated, and shared across components without tightly coupling them. Inspired by reactive UI frameworks and functional programming, this pattern lets you build clean, testable data flows without reaching for full-blown frameworks. In this post, we’ll explore how to implement binding properties in Go using generics, what problems this pattern solves, and how it can improve the architecture of your applications. Whether you’re building a CLI tool, a backend service, or even experimenting with GUIs in Go, binding properties offer a powerful way to manage state more declaratively.
Implementation in Go
Start with the usual:
package main
import (
"fmt"
"sync"
)
Our app will be doing some printing so the fmt is needed, and the sync package so we can use the mutex type. We use that to make the pattern thread safe.
The observer function
Before implementing the observable properties we need two things first. The first is a function definition which is called whenever a property changes. This is a generic function of type T which takes in the old value and the new value. The second one is the observer id, which is basically an int. Each observer on a property receives such an id. When unsubscribing an observer from a property we need this id. The two definitions look like this:
type Observer[T any] func(oldValue, newValue T)
type ObserverID int
The property type
The observable property is the heart of this pattern, and it looks like this:
type ObservableProperty[T any] struct {
value T
observers map[ObserverID]Observer[T]
id ObserverID
mu sync.RWMutex
}
Line by line:
- This struct is a generic struct, and it will contain values of values of type
T - We start by storing the current value in the field
valuewhich is of typeT - Next comes a map of observers, with the observer id as the key and the observer as the value. This allow for easier unsubscribing.
- The current max id. We could of course use the map to calculate the id of a new observer, however this can lead to problems when unsubscribing observers.
- Finally a mutex, so we can lock the property when accessing it. This is a read write mutex. This means that we can get a read lock which is non-exclusive but prevents modifying the underlying resource, or a write lock, which is exclusive and allows modifying the underlying resource.
Now we can create a new ObservableProperty:
func NewObservableProperty[T any](initialValue T) *ObservableProperty[T] {
return &ObservableProperty[T]{
value: initialValue,
observers: make(map[ObserverID]Observer[T]),
id: 0,
}
}
This constructor:
- sets the value to its initial value.
- creates an empty map of observers
- and sets the current id to 0.
Like many other functions, this is a generic function.
The getter and setter methods
Getting a value looks like this:
func (p *ObservableProperty[T]) Get() T {
p.mu.RLock()
defer p.mu.RUnlock()
return p.value
}
Line by line:
- Make sure the property has a read lock. This is a non-exclusive lock, allowing only read access.
- Make sure the lock is released before the function ends by using the defer keyword.
- Return the current value.
Note that this is a generic method.
Another generic method we use to set the value. This has a bit more logic, because when setting a value, all the subscribed observers will have to be notified.
func (p *ObservableProperty[T]) Set(newValue T) {
p.mu.Lock()
oldValue := p.value
p.value = newValue
observers := make(map[ObserverID]Observer[T])
for id, obs := range p.observers {
observers[id] = obs
}
p.mu.Unlock()
for _, observer := range observers {
func(obs Observer[T]) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Observer panic: %v\n", r)
}
}()
obs(oldValue, newValue)
}(observer)
}
}
Some notes:
- The method starts by acquiring a write lock on the property.
- The value is set, and we save the old value.
- Next, while the property is still locked, a copy is made of the observers map. This is done to prevent deadlocks.
- The property is unlocked.
- Now for the unlocked property we iterate over the observer map, and execute the observer functions.
- The defer is used to handle any panics. That way, if one observer panics, it does not interfere with the other observers. However, the panic here is handled by a single print statement, in a production environment error should be more elaborate.
Why is the observer map copied? There are two reasons for that:
- Thread safety: Concurrent subcribe and unsubscribe actions do not interfere with the execution of the observer functions.
- Performance: The observers can run without blocking other operations.
Subscribing and unsubscribing
To add observers we need a subscribe method:
func (p *ObservableProperty[T]) Subscribe(observer Observer[T]) ObserverID {
p.mu.Lock()
defer p.mu.Unlock()
id := p.id
p.id++
p.observers[id] = observer
return id
}
Line by line:
- We start by locking the property, this is a write lock, since we will modifying the observers map.
- Also we make sure that before the method ends, we unlock the property by using the defer keyword.
- Next we calculate the new observer id.
- We insert the new observer into the map with new id as its key.
- We return the new id.
The unsubscribe method is now very short:
func (p *ObservableProperty[T]) Unsubscribe(id ObserverID) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.observers, id)
}
Line by line:
- Like in the previous method we lock the property, this is a write lock as well, as we will be modifying the observers map. We also make sure by using defer that the lock is released.
- Finally we delete the observer by providing its id to the
delete()method.
Counting the observers
For testing purposes we introduce a method to return the number of observers:
func (p *ObservableProperty[T]) GetObserverCount() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.observers)
}
The Person struct
To test this setup we will introduce a Person struct with only two fields, a name of type string and an age of type int, or rather these values are wrapped in an ObservableProperty:
type Person struct {
Name *ObservableProperty[string]
Age *ObservableProperty[int]
}
The constructor looks like this:
func NewPerson(name string, age int) *Person {
return &Person{
Name: NewObservableProperty(name),
Age: NewObservableProperty(age),
}
}
All this does is set the initial values for name and age.
Finally we have some getters and setters:
func (p *Person) SetName(name string) {
p.Name.Set(name)
}
func (p *Person) GetName() string {
return p.Name.Get()
}
func (p *Person) SetAge(age int) {
p.Age.Set(age)
}
func (p *Person) GetAge() int {
return p.Age.Get()
}
Time to test
Let’s test our setup:
func main() {
fmt.Println("Type-safe Person binding properties example")
person := NewPerson("Alice", 30)
nameID1 := person.Name.Subscribe(func(oldValue, newValue string) {
fmt.Printf("Name Observer 1: Name changed from '%s' to '%s'\n", oldValue, newValue)
})
nameID2 := person.Name.Subscribe(func(oldValue, newValue string) {
fmt.Printf("Name Observer 2: Property update - Name: %s -> %s\n", oldValue, newValue)
})
ageID1 := person.Age.Subscribe(func(oldValue, newValue int) {
fmt.Printf("Age Observer: Age changed from %d to %d\n", oldValue, newValue)
})
person.Age.Subscribe(func(oldValue, newValue int) {
fmt.Printf("Logger: [AGE CHANGE] Age: %d -> %d\n", oldValue, newValue)
})
fmt.Printf("Initial state - Name: %s, Age: %d\n", person.GetName(), person.GetAge())
fmt.Printf("Name observers: %d, Age observers: %d\n\n",
person.Name.GetObserverCount(), person.Age.GetObserverCount())
fmt.Println("Setting name to 'Bob'...")
person.SetName("Bob")
fmt.Printf("Updated Name: %s\n\n", person.GetName())
fmt.Println("Setting age to 35...")
person.SetAge(35)
fmt.Printf("Updated Age: %d\n\n", person.GetAge())
fmt.Println("Unsubscribing Name Observer 1...")
person.Name.Unsubscribe(nameID1)
fmt.Printf("Name observer count after unsubscribe: %d\n\n", person.Name.GetObserverCount())
fmt.Println("Setting name to 'Charlie'...")
person.SetName("Charlie")
fmt.Printf("Updated Name: %s\n\n", person.GetName())
fmt.Println("Unsubscribing Name Observer 2...")
person.Name.Unsubscribe(nameID2)
fmt.Printf("Name observer count: %d\n\n", person.Name.GetObserverCount())
fmt.Println("Unsubscribing Age Observer...")
person.Age.Unsubscribe(ageID1)
fmt.Printf("Age observer count: %d\n\n", person.Age.GetObserverCount())
fmt.Println("Setting age to 40...")
person.SetAge(40)
fmt.Printf("Final Age: %d\n", person.GetAge())
}
Some things to note:
- The observer functions are now typesafe, with either string or int arguments in this test case.
- The
Subscribe()method returns an observer id, which when stored can be used to unsubscribe the observer.
Conclusion
Using generics implementing the binding property can be quite elegant. Another thing is that the observer functions are now typesafe.
However, the use of the setter and getter methods is compulsory to leverage this pattern, if not the observers will not be called, and what is even worse, the state of the objects may be come inconsistent especially in multi-threaded environment. Using the setter and getter methods will prevent such inconsistency.



