Thread-Safe Struct Design
Part of the Object Oriented Programming section of Coddy's GO journey — lesson 65 of 107.
Now that you understand mutexes and WaitGroups, let's combine them to design structs that are safe to use from multiple goroutines simultaneously. A thread-safe struct encapsulates synchronization within its methods, so callers don't need to worry about locking.
The pattern is straightforward: embed a mutex in your struct and lock it in every method that accesses shared state:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Notice that even the read-only Value() method locks the mutex. Without this, one goroutine might read while another writes, causing a data race. If reads are far more common than writes, use sync.RWMutex instead and call RLock() for reads.
A key design principle: keep the mutex private. By using a lowercase field name (mu), you prevent external code from accessing it directly. All synchronization happens through your methods, giving you full control over thread safety.
For structs with multiple fields, protect all related fields with the same mutex to ensure consistent state:
type Account struct {
mu sync.Mutex
balance int
history []string
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
a.history = append(a.history, fmt.Sprintf("+%d", amount))
}Both balance and history are updated atomically—no goroutine can observe an inconsistent state where one changed but not the other.
Challenge
EasyLet's build a thread-safe bank account system that demonstrates proper encapsulation of synchronization within struct methods. Your account will handle concurrent deposits, withdrawals, and balance checks safely without exposing any locking details to callers.
You'll organize your code across two files:
account.go: Define your thread-safe bank account.Create a
BankAccountstruct with an embeddedsync.Mutex, abalancefield (int), and atransactionsslice that records all successful operations as strings.Implement these methods:
NewBankAccount(initial int) *BankAccount- Creates a new account with the given initial balance and an empty transactions sliceDeposit(amount int)- Adds the amount to the balance and records the transaction as+[amount]Withdraw(amount int) bool- If sufficient funds exist, subtracts the amount, records-[amount], and returnstrue. Otherwise returnsfalsewithout modifying anythingBalance() int- Returns the current balanceHistory() []string- Returns a copy of the transactions slice
Every method that accesses the struct's fields must lock the mutex to ensure thread safety. Use
deferfor unlocking. Keep the mutex and all fields unexported (lowercase) so external code must use your methods.main.go: Process banking operations and demonstrate your thread-safe account.Read the initial balance, then the number of operations. For each operation, read the type (
deposit,withdraw, orbalance) and for deposit/withdraw, read the amount.Print results for each operation:
deposit: PrintDeposited [amount], Balance: [new balance]withdraw: PrintWithdrew [amount], Balance: [new balance]if successful, orWithdrawal failed: insufficient fundsif notbalance: PrintCurrent balance: [balance]
After all operations, print the transaction history with each entry on a new line, prefixed by
History:for the first entry only.
The following inputs will be provided:
- Line 1: Initial balance (integer)
- Line 2: Number of operations (integer)
- Following lines: For each operation, the type (
deposit,withdraw, orbalance), and for deposit/withdraw, the amount on the next line
For example, given:
100
5
deposit
50
balance
withdraw
30
withdraw
200
balanceYour output should be:
Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30The key principle here is that all synchronization is hidden inside your BankAccount methods. Callers simply use Deposit(), Withdraw(), and Balance() without ever thinking about locks—your struct handles thread safety internally.
Cheat sheet
A thread-safe struct encapsulates synchronization within its methods by embedding a mutex and locking it in every method that accesses shared state:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}Key principles:
- Lock the mutex in all methods that access shared state, including read-only methods, to prevent data races
- Use
deferto ensure the mutex is unlocked even if the function returns early - Keep the mutex private (lowercase field name) so external code cannot access it directly
- For read-heavy workloads, use
sync.RWMutexand callRLock()for reads
For structs with multiple fields, protect all related fields with the same mutex to ensure consistent state:
type Account struct {
mu sync.Mutex
balance int
history []string
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
a.history = append(a.history, fmt.Sprintf("+%d", amount))
}This ensures that both balance and history are updated atomically—no goroutine can observe an inconsistent state.
Try it yourself
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
reader := bufio.NewReader(os.Stdin)
// Read initial balance
initialStr, _ := reader.ReadString('\n')
initial, _ := strconv.Atoi(strings.TrimSpace(initialStr))
// Read number of operations
numOpsStr, _ := reader.ReadString('\n')
numOps, _ := strconv.Atoi(strings.TrimSpace(numOpsStr))
// Create the bank account
account := NewBankAccount(initial)
// Process each operation
for i := 0; i < numOps; i++ {
opType, _ := reader.ReadString('\n')
opType = strings.TrimSpace(opType)
switch opType {
case "deposit":
amountStr, _ := reader.ReadString('\n')
amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
// TODO: Call Deposit and print the result
// Format: "Deposited [amount], Balance: [new balance]"
case "withdraw":
amountStr, _ := reader.ReadString('\n')
amount, _ := strconv.Atoi(strings.TrimSpace(amountStr))
// TODO: Call Withdraw and print the appropriate result
// If successful: "Withdrew [amount], Balance: [new balance]"
// If failed: "Withdrawal failed: insufficient funds"
_ = amount // Remove this line when you implement
case "balance":
// TODO: Call Balance and print the result
// Format: "Current balance: [balance]"
}
}
// TODO: Print transaction history
// First entry should be prefixed with "History: "
// Subsequent entries should be on new lines without prefix
}
This lesson includes a short quiz. Start the lesson to answer it and track your progress.
All lessons in Object Oriented Programming
1Fundamentals of Go OOP
External FilesGo Workspace & ModulesPackages & ImportsExported vs Unexported NamesIntroduction to OOP in GoStructs as ClassesDefining Methods on StructsPointer vs Value ReceiversStruct InitializationConstructor FunctionsRecap - Simple Calculator4Interfaces
Introduction to InterfacesImplicit ImplementationInterface as ContractEmpty Interface (any)Type AssertionType SwitchInterface CompositionStringer & Error InterfacesRecap - Shape Calculator7Encapsulation
Exported vs Unexported FieldsPackage-Level EncapsulationGetter & Setter MethodsInformation Hiding in GoRecap - Student Records10Generics (Go 1.18+)
Introduction to GenericsType ParametersType ConstraintsGeneric StructsGeneric Methods WorkaroundRecap - Generic Collection2Types & Structs Deep Dive
Basic & Composite TypesCustom Type DefinitionsStruct TagsAnonymous StructsNested StructsZero Values & DefaultsRecap - Contact Book5Composition Over Inheritance
Why Go Has No InheritanceStruct Embedding BasicsMethod PromotionEmbedding Multiple StructsEmbedding vs AggregationShadowing Embedded MethodsRecap - Employee Hierarchy8Error Handling & OOP
The error InterfaceCustom Error TypesError Wrapping (fmt.Errorf)Sentinel Errorserrors.Is() and errors.As()Panic, Defer, and RecoverRecap - File Parser3Pointers & Memory
Pointer Basics in GoPointers to StructsPass by Value vs ReferenceThe new() FunctionGarbage Collection in GoRecap - Linked List Builder6Polymorphism in Go
Polymorphism via InterfacesDuck Typing in GoInterface Satisfaction RulesPolymorphic CollectionsDependency InjectionRecap - Payment Processor9Concurrency & OOP
Goroutines BasicsChannels & CommunicationBuffered vs Unbuffered ChanSelect Statementsync.Mutex & sync.RWMutexsync.WaitGroupThread-Safe Struct DesignRecap - Worker Pool