Menu
Coddy logo textTech

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 icon

Challenge

Easy

Let'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 BankAccount struct with an embedded sync.Mutex, a balance field (int), and a transactions slice 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 slice
    • Deposit(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 returns true. Otherwise returns false without modifying anything
    • Balance() int - Returns the current balance
    • History() []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 defer for 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, or balance) and for deposit/withdraw, read the amount.

    Print results for each operation:

    • deposit: Print Deposited [amount], Balance: [new balance]
    • withdraw: Print Withdrew [amount], Balance: [new balance] if successful, or Withdrawal failed: insufficient funds if not
    • balance: Print Current 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, or balance), and for deposit/withdraw, the amount on the next line

For example, given:

100
5
deposit
50
balance
withdraw
30
withdraw
200
balance

Your output should be:

Deposited 50, Balance: 150
Current balance: 150
Withdrew 30, Balance: 120
Withdrawal failed: insufficient funds
Current balance: 120
History: +50
-30

The 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 defer to 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.RWMutex and call RLock() 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
}
quiz iconTest yourself

This lesson includes a short quiz. Start the lesson to answer it and track your progress.

All lessons in Object Oriented Programming