Go Structs and Interfaces - Building Custom Types and Contracts
Learning Objectives: By the end of this lesson, you will be able to:
- Design and implement custom struct types for complex data modeling
- Master struct embedding for composition-based inheritance
- Understand and implement Go’s implicit interface system
- Apply interface-driven design for flexible, testable code
- Use type assertions and type switches effectively
- Build polymorphic systems using interfaces
- Design clean APIs using interface contracts
- Implement common design patterns with structs and interfaces
1. Welcome and Introduction (5 minutes)
Icebreaker Activity
Imagine you’re designing a video game. You have different types of characters (warriors, mages, archers), each with unique abilities, but they all share common traits like health and movement. How would you organize this without repeating code? Go’s structs and interfaces provide an elegant solution - structs let you model complex entities, while interfaces define contracts for behavior. What would happen if you could make your code both flexible and type-safe?
Lesson Overview
- Duration: 10-12 hours
- Prerequisites: Go functions, methods, arrays, slices, and maps
- Materials Needed: Go development environment, understanding of basic OOP concepts
2. Learning Objectives and Session Outline (5 minutes)
What We’ll Cover Today:
- Struct Fundamentals - Custom types and data modeling
- Methods on Structs - Attaching behavior to data
- Struct Embedding - Composition over inheritance
- Interface Basics - Defining contracts and implicit implementation
- Polymorphism - Working with different types through common interfaces
- Advanced Interface Patterns - Empty interfaces, type assertions, and switches
- Real-world Design - Building flexible, maintainable systems
- Best Practices - Interface design principles and common patterns
Success Criteria:
By the end of this session, you should be able to design complex data models using structs, create flexible APIs with interfaces, and understand Go’s unique approach to object-oriented programming.
3. Foundation Knowledge (15-20 minutes)
Understanding Go’s Type System Philosophy
Go takes a different approach to object-oriented programming compared to traditional OOP languages. Instead of classes and inheritance, Go uses structs and composition, with interfaces providing the abstraction layer.
Real-World Analogy: Think of structs like blueprints for building different types of vehicles (car, truck, motorcycle). Each blueprint defines the structure and components. Interfaces are like driver’s licenses - they define what capabilities something must have to be “drivable” (start, stop, turn), regardless of whether it’s a car or motorcycle.
Why This Matters: Go’s approach provides:
- Composition over Inheritance: More flexible than traditional class hierarchies
- Implicit Interfaces: No explicit “implements” keyword - duck typing with compile-time safety
- Zero-cost Abstractions: Interfaces compile to efficient code
- Testability: Easy to mock and test through interfaces
- Decoupling: Code depends on behavior, not concrete types
Core Concepts
Structs as Data Containers
- Custom Types: Group related data together
- Methods: Attach behavior to struct types
- Embedding: Include other structs for composition
- Value vs Pointer Semantics: Control how data is passed and modified
Interfaces as Behavior Contracts
- Implicit Implementation: Types satisfy interfaces automatically
- Polymorphism: Different types can be used interchangeably
- Interface Segregation: Small, focused interfaces are preferred
- Empty Interface:
interface{}can hold any type
Go’s OOP Principles
- Favor composition over inheritance
- Design interfaces, not implementations
- Keep interfaces small and focused
- Accept interfaces, return concrete types
Key Terminology
- Struct: Custom type that groups related data fields
- Field: Individual data element within a struct
- Method Set: Collection of methods associated with a type
- Embedding: Including one struct inside another
- Interface: Contract defining required methods
- Satisfying an Interface: Type has all methods required by interface
- Type Assertion: Extracting concrete type from interface
- Polymorphism: Using different types through common interface
4. Detailed Explanation and Examples (25-30 minutes)
Understanding Go’s Unique Approach to OOP
Go’s design philosophy emphasizes simplicity and composition. Unlike traditional OOP languages with complex inheritance hierarchies, Go uses:
Composition over Inheritance
Instead of “is-a” relationships (inheritance), Go encourages “has-a” relationships (composition). This leads to more flexible and maintainable code.
Interface Satisfaction
Go’s interfaces are satisfied implicitly. If a type has all the methods an interface requires, it automatically satisfies that interface. This enables:
- Retroactive Interface Satisfaction: Add interfaces to existing types
- Multiple Interface Satisfaction: One type can satisfy many interfaces
- Decoupled Design: Interfaces can be defined where they’re used
Practical Applications
Structs and interfaces are essential for:
- API Design: Clean, testable HTTP handlers and services
- Database Modeling: Representing entities and their relationships
- Plugin Systems: Defining contracts for extensible applications
- Microservices: Standardizing communication between services
- Testing: Creating mocks and test doubles
Common Misconceptions
-
Misconception: Go doesn’t support object-oriented programming Reality: Go supports OOP through structs, methods, and interfaces
-
Misconception: Embedding is inheritance Reality: Embedding is composition - the embedded type is a field
-
Misconception: Interfaces must be large and comprehensive Reality: Go favors small, focused interfaces (often just 1-2 methods)
5. Live Coding Demonstration (20-25 minutes)
Example 1: Struct Fundamentals and Methods
package main
import (
"fmt"
"math"
"time"
)
// Basic struct definition
type Person struct {
FirstName string
LastName string
Age int
Email string
}
// Struct with different field types
type BankAccount struct {
AccountNumber string
Balance float64
Owner Person // Embedded struct
CreatedAt time.Time
IsActive bool
}
// Struct with tags (useful for JSON, database mapping)
type Product struct {
ID int `json:"id" db:"product_id"`
Name string `json:"name" db:"product_name"`
Price float64 `json:"price" db:"price"`
Description string `json:"description,omitempty" db:"description"`
}
// Methods with value receivers
func (p Person) FullName() string {
return p.FirstName + " " + p.LastName
}
func (p Person) IsAdult() bool {
return p.Age >= 18
}
func (p Person) Greet() string {
return fmt.Sprintf("Hello, I'm %s", p.FullName())
}
// Methods with pointer receivers (can modify the receiver)
func (b *BankAccount) Deposit(amount float64) error {
if amount <= 0 {
return fmt.Errorf("deposit amount must be positive")
}
b.Balance += amount
return nil
}
func (b *BankAccount) Withdraw(amount float64) error {
if amount <= 0 {
return fmt.Errorf("withdrawal amount must be positive")
}
if amount > b.Balance {
return fmt.Errorf("insufficient funds")
}
b.Balance -= amount
return nil
}
func (b BankAccount) GetBalance() float64 {
return b.Balance
}
// Method that returns multiple values
func (b BankAccount) AccountInfo() (string, float64, bool) {
return b.AccountNumber, b.Balance, b.IsActive
}
func main() {
fmt.Println("=== Struct Creation and Usage ===")
// Different ways to create structs
// Method 1: Field by field
var person1 Person
person1.FirstName = "John"
person1.LastName = "Doe"
person1.Age = 30
person1.Email = "john@example.com"
// Method 2: Struct literal with field names
person2 := Person{
FirstName: "Jane",
LastName: "Smith",
Age: 25,
Email: "jane@example.com",
}
// Method 3: Struct literal without field names (not recommended)
person3 := Person{"Bob", "Johnson", 35, "bob@example.com"}
// Method 4: Partial initialization
person4 := Person{
FirstName: "Alice",
LastName: "Williams",
// Age and Email will be zero values
}
fmt.Printf("Person1: %+v\n", person1)
fmt.Printf("Person2: %+v\n", person2)
fmt.Printf("Person3: %+v\n", person3)
fmt.Printf("Person4: %+v\n", person4)
// Using methods
fmt.Println("\n=== Method Usage ===")
fmt.Printf("%s\n", person1.Greet())
fmt.Printf("Full name: %s\n", person2.FullName())
fmt.Printf("Is adult: %t\n", person2.IsAdult())
// Bank account example
fmt.Println("\n=== Bank Account Example ===")
account := BankAccount{
AccountNumber: "ACC123456",
Balance: 1000.0,
Owner: person1,
CreatedAt: time.Now(),
IsActive: true,
}
fmt.Printf("Initial account: %+v\n", account)
fmt.Printf("Owner: %s\n", account.Owner.FullName())
// Deposit money
if err := account.Deposit(500.0); err != nil {
fmt.Printf("Deposit error: %s\n", err)
} else {
fmt.Printf("After deposit: Balance = %.2f\n", account.GetBalance())
}
// Withdraw money
if err := account.Withdraw(200.0); err != nil {
fmt.Printf("Withdrawal error: %s\n", err)
} else {
fmt.Printf("After withdrawal: Balance = %.2f\n", account.GetBalance())
}
// Try to withdraw too much
if err := account.Withdraw(2000.0); err != nil {
fmt.Printf("Withdrawal error: %s\n", err)
}
// Get account info
accNum, balance, active := account.AccountInfo()
fmt.Printf("Account: %s, Balance: %.2f, Active: %t\n", accNum, balance, active)
}
Explanation:
- Lines 8-13: Basic struct with various field types
- Lines 15-21: Struct composition with embedded Person
- Lines 24-28: Struct tags for metadata (JSON, database mapping)
- Lines 31-39: Methods with value receivers - work on copies
- Lines 42-56: Methods with pointer receivers - can modify original
- Lines 90-106: Different struct initialization patterns
Example 2: Struct Embedding and Composition
package main
import (
"fmt"
"time"
)
// Base types
type Address struct {
Street string
City string
State string
ZipCode string
Country string
}
type ContactInfo struct {
Email string
Phone string
}
// Embedding example
type Employee struct {
ID int
FirstName string
LastName string
Address Address // Regular field
ContactInfo // Anonymous field (embedded)
Department string
Salary float64
HireDate time.Time
}
// Manager embeds Employee
type Manager struct {
Employee // Embedded Employee
TeamSize int
Budget float64
Reports []Employee
}
// Methods on embedded types
func (a Address) FullAddress() string {
return fmt.Sprintf("%s, %s, %s %s, %s",
a.Street, a.City, a.State, a.ZipCode, a.Country)
}
func (c ContactInfo) PrimaryContact() string {
if c.Email != "" {
return c.Email
}
return c.Phone
}
// Methods on Employee
func (e Employee) FullName() string {
return e.FirstName + " " + e.LastName
}
func (e Employee) YearsOfService() int {
return int(time.Since(e.HireDate).Hours() / 24 / 365)
}
// Methods on Manager
func (m Manager) AddReport(emp Employee) {
m.Reports = append(m.Reports, emp)
}
func (m Manager) TeamInfo() string {
return fmt.Sprintf("Manager %s leads %d people with budget $%.2f",
m.FullName(), m.TeamSize, m.Budget)
}
// Demonstrating method promotion
func (m Manager) GetContactInfo() string {
// This calls the embedded ContactInfo's method
return m.PrimaryContact()
}
// Interface for different shapes
type Shape interface {
Area() float64
Perimeter() float64
}
// Rectangle struct
type Rectangle struct {
Width float64
Height float64
}
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
func (r Rectangle) Perimeter() float64 {
return 2 * (r.Width + r.Height)
}
// Circle struct
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * math.Pi * c.Radius
}
// Function that works with any Shape
func PrintShapeInfo(s Shape) {
fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}
func main() {
fmt.Println("=== Struct Embedding ===")
// Create an employee
emp := Employee{
ID: 1001,
FirstName: "John",
LastName: "Doe",
Address: Address{
Street: "123 Main St",
City: "Anytown",
State: "CA",
ZipCode: "12345",
Country: "USA",
},
ContactInfo: ContactInfo{
Email: "john.doe@company.com",
Phone: "+1-555-0123",
},
Department: "Engineering",
Salary: 75000.0,
HireDate: time.Date(2020, 1, 15, 0, 0, 0, 0, time.UTC),
}
fmt.Printf("Employee: %s\n", emp.FullName())
fmt.Printf("Address: %s\n", emp.Address.FullAddress())
// Accessing embedded fields directly (field promotion)
fmt.Printf("Email: %s\n", emp.Email) // Same as emp.ContactInfo.Email
fmt.Printf("Phone: %s\n", emp.Phone) // Same as emp.ContactInfo.Phone
fmt.Printf("Primary contact: %s\n", emp.PrimaryContact())
fmt.Printf("Years of service: %d\n", emp.YearsOfService())
// Create a manager
fmt.Println("\n=== Manager with Embedded Employee ===")
manager := Manager{
Employee: emp, // Embedding an employee
TeamSize: 5,
Budget: 500000.0,
}
// Manager has access to all Employee methods and fields
fmt.Printf("Manager: %s\n", manager.FullName())
fmt.Printf("Manager department: %s\n", manager.Department)
fmt.Printf("Manager email: %s\n", manager.Email)
fmt.Printf("%s\n", manager.TeamInfo())
// Demonstrating polymorphism with interfaces
fmt.Println("\n=== Interface Polymorphism ===")
rect := Rectangle{Width: 10, Height: 5}
circle := Circle{Radius: 7}
fmt.Println("Rectangle:")
PrintShapeInfo(rect)
fmt.Println("Circle:")
PrintShapeInfo(circle)
// Slice of different shapes
shapes := []Shape{rect, circle}
fmt.Println("All shapes:")
for i, shape := range shapes {
fmt.Printf("Shape %d: ", i+1)
PrintShapeInfo(shape)
}
}
Example 3: Interface Mastery and Type Assertions
package main
import (
"fmt"
"io"
"strings"
)
// Define various interfaces
type Writer interface {
Write([]byte) (int, error)
}
type Reader interface {
Read([]byte) (int, error)
}
type ReadWriter interface {
Reader
Writer
}
type Closer interface {
Close() error
}
// Interface composition
type ReadWriteCloser interface {
Reader
Writer
Closer
}
// Custom writer implementation
type UpperCaseWriter struct {
writer io.Writer
}
func (ucw UpperCaseWriter) Write(data []byte) (int, error) {
upperData := strings.ToUpper(string(data))
return ucw.writer.Write([]byte(upperData))
}
// Buffer that implements multiple interfaces
type Buffer struct {
data []byte
position int
closed bool
}
func NewBuffer() *Buffer {
return &Buffer{
data: make([]byte, 0),
}
}
func (b *Buffer) Write(data []byte) (int, error) {
if b.closed {
return 0, fmt.Errorf("buffer is closed")
}
b.data = append(b.data, data...)
return len(data), nil
}
func (b *Buffer) Read(data []byte) (int, error) {
if b.closed {
return 0, fmt.Errorf("buffer is closed")
}
available := len(b.data) - b.position
if available == 0 {
return 0, io.EOF
}
n := copy(data, b.data[b.position:])
b.position += n
return n, nil
}
func (b *Buffer) Close() error {
b.closed = true
return nil
}
func (b *Buffer) String() string {
return string(b.data)
}
func (b *Buffer) Reset() {
b.data = b.data[:0]
b.position = 0
b.closed = false
}
// Different types implementing the same interface
type Animal interface {
Speak() string
Move() string
}
type Dog struct {
Name string
Breed string
}
func (d Dog) Speak() string {
return "Woof!"
}
func (d Dog) Move() string {
return "Running on four legs"
}
type Bird struct {
Name string
Species string
}
func (b Bird) Speak() string {
return "Tweet!"
}
func (b Bird) Move() string {
return "Flying with wings"
}
type Fish struct {
Name string
Type string
}
func (f Fish) Speak() string {
return "Blub blub"
}
func (f Fish) Move() string {
return "Swimming with fins"
}
// Function that works with any Animal
func DescribeAnimal(a Animal) {
fmt.Printf("The animal says: %s\n", a.Speak())
fmt.Printf("Movement: %s\n", a.Move())
// Type assertion to access specific methods
switch v := a.(type) {
case Dog:
fmt.Printf("This is a %s dog named %s\n", v.Breed, v.Name)
case Bird:
fmt.Printf("This is a %s bird named %s\n", v.Species, v.Name)
case Fish:
fmt.Printf("This is a %s fish named %s\n", v.Type, v.Name)
default:
fmt.Printf("Unknown animal type: %T\n", v)
}
}
// Empty interface example
func ProcessAnyValue(value interface{}) {
fmt.Printf("Processing value: %v (type: %T)\n", value, value)
// Type assertion with ok pattern
if str, ok := value.(string); ok {
fmt.Printf(" String length: %d\n", len(str))
} else if num, ok := value.(int); ok {
fmt.Printf(" Number squared: %d\n", num*num)
} else if slice, ok := value.([]int); ok {
fmt.Printf(" Slice sum: %d\n", sumSlice(slice))
} else {
fmt.Printf(" Unsupported type\n")
}
}
func sumSlice(slice []int) int {
sum := 0
for _, v := range slice {
sum += v
}
return sum
}
func main() {
fmt.Println("=== Interface Implementation ===")
// Using custom writer
var buf strings.Builder
upperWriter := UpperCaseWriter{writer: &buf}
upperWriter.Write([]byte("Hello, World!"))
fmt.Printf("Upper case output: %s\n", buf.String())
// Using custom buffer
fmt.Println("\n=== Custom Buffer ===")
buffer := NewBuffer()
// Buffer implements Writer
buffer.Write([]byte("Hello, "))
buffer.Write([]byte("Go World!"))
fmt.Printf("Buffer content: %s\n", buffer.String())
// Buffer implements Reader
readData := make([]byte, 5)
n, err := buffer.Read(readData)
if err != nil {
fmt.Printf("Read error: %s\n", err)
} else {
fmt.Printf("Read %d bytes: %s\n", n, string(readData[:n]))
}
// Buffer implements Closer
buffer.Close()
// Polymorphism with animals
fmt.Println("\n=== Animal Polymorphism ===")
animals := []Animal{
Dog{Name: "Buddy", Breed: "Golden Retriever"},
Bird{Name: "Tweety", Species: "Canary"},
Fish{Name: "Nemo", Type: "Clownfish"},
}
for i, animal := range animals {
fmt.Printf("\nAnimal %d:\n", i+1)
DescribeAnimal(animal)
}
// Empty interface examples
fmt.Println("\n=== Empty Interface ===")
values := []interface{}{
"Hello, Go!",
42,
[]int{1, 2, 3, 4, 5},
3.14,
true,
}
for _, value := range values {
ProcessAnyValue(value)
fmt.Println()
}
// Type assertion examples
fmt.Println("=== Type Assertions ===")
var i interface{} = "Hello"
// Safe type assertion
if s, ok := i.(string); ok {
fmt.Printf("String value: %s\n", s)
} else {
fmt.Println("Not a string")
}
// This would panic if we didn't check:
// s := i.(int) // panic: interface conversion
// Type switch
switch v := i.(type) {
case string:
fmt.Printf("String: %s (length: %d)\n", v, len(v))
case int:
fmt.Printf("Integer: %d\n", v)
case bool:
fmt.Printf("Boolean: %t\n", v)
default:
fmt.Printf("Unknown type: %T\n", v)
}
}
Example 4: Real-World Design Patterns
package main
import (
"fmt"
"time"
)
// Repository pattern with interfaces
type User struct {
ID int
Username string
Email string
Created time.Time
}
type UserRepository interface {
Save(user *User) error
FindByID(id int) (*User, error)
FindByUsername(username string) (*User, error)
Delete(id int) error
}
// In-memory implementation
type InMemoryUserRepository struct {
users map[int]*User
nextID int
}
func NewInMemoryUserRepository() *InMemoryUserRepository {
return &InMemoryUserRepository{
users: make(map[int]*User),
nextID: 1,
}
}
func (r *InMemoryUserRepository) Save(user *User) error {
if user.ID == 0 {
user.ID = r.nextID
user.Created = time.Now()
r.nextID++
}
r.users[user.ID] = user
return nil
}
func (r *InMemoryUserRepository) FindByID(id int) (*User, error) {
user, exists := r.users[id]
if !exists {
return nil, fmt.Errorf("user with ID %d not found", id)
}
return user, nil
}
func (r *InMemoryUserRepository) FindByUsername(username string) (*User, error) {
for _, user := range r.users {
if user.Username == username {
return user, nil
}
}
return nil, fmt.Errorf("user with username %s not found", username)
}
func (r *InMemoryUserRepository) Delete(id int) error {
if _, exists := r.users[id]; !exists {
return fmt.Errorf("user with ID %d not found", id)
}
delete(r.users, id)
return nil
}
// Service layer using repository
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) CreateUser(username, email string) (*User, error) {
// Validation
if username == "" {
return nil, fmt.Errorf("username cannot be empty")
}
if email == "" {
return nil, fmt.Errorf("email cannot be empty")
}
// Check if username already exists
if _, err := s.repo.FindByUsername(username); err == nil {
return nil, fmt.Errorf("username %s already exists", username)
}
user := &User{
Username: username,
Email: email,
}
if err := s.repo.Save(user); err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}
return user, nil
}
func (s *UserService) GetUser(id int) (*User, error) {
return s.repo.FindByID(id)
}
// Strategy pattern with interfaces
type PaymentProcessor interface {
ProcessPayment(amount float64) error
GetName() string
}
type CreditCardProcessor struct {
CardNumber string
CVV string
}
func (cc CreditCardProcessor) ProcessPayment(amount float64) error {
fmt.Printf("Processing $%.2f payment with credit card ending in %s\n",
amount, cc.CardNumber[len(cc.CardNumber)-4:])
return nil
}
func (cc CreditCardProcessor) GetName() string {
return "Credit Card"
}
type PayPalProcessor struct {
Email string
}
func (pp PayPalProcessor) ProcessPayment(amount float64) error {
fmt.Printf("Processing $%.2f payment via PayPal for %s\n", amount, pp.Email)
return nil
}
func (pp PayPalProcessor) GetName() string {
return "PayPal"
}
type Order struct {
ID int
Amount float64
Processor PaymentProcessor
}
func (o *Order) ProcessPayment() error {
fmt.Printf("Order %d: Using %s payment processor\n", o.ID, o.Processor.GetName())
return o.Processor.ProcessPayment(o.Amount)
}
// Observer pattern
type EventListener interface {
OnEvent(event string, data interface{})
}
type EmailNotifier struct {
Email string
}
func (en EmailNotifier) OnEvent(event string, data interface{}) {
fmt.Printf("Email notification to %s: %s - %v\n", en.Email, event, data)
}
type SMSNotifier struct {
Phone string
}
func (sn SMSNotifier) OnEvent(event string, data interface{}) {
fmt.Printf("SMS notification to %s: %s - %v\n", sn.Phone, event, data)
}
type EventManager struct {
listeners []EventListener
}
func NewEventManager() *EventManager {
return &EventManager{
listeners: make([]EventListener, 0),
}
}
func (em *EventManager) Subscribe(listener EventListener) {
em.listeners = append(em.listeners, listener)
}
func (em *EventManager) Notify(event string, data interface{}) {
for _, listener := range em.listeners {
listener.OnEvent(event, data)
}
}
func main() {
// Repository pattern demo
fmt.Println("=== Repository Pattern ===")
repo := NewInMemoryUserRepository()
service := NewUserService(repo)
// Create users
user1, err := service.CreateUser("john_doe", "john@example.com")
if err != nil {
fmt.Printf("Error creating user: %s\n", err)
} else {
fmt.Printf("Created user: %+v\n", user1)
}
user2, err := service.CreateUser("jane_smith", "jane@example.com")
if err != nil {
fmt.Printf("Error creating user: %s\n", err)
} else {
fmt.Printf("Created user: %+v\n", user2)
}
// Try to create duplicate
_, err = service.CreateUser("john_doe", "john2@example.com")
if err != nil {
fmt.Printf("Expected error: %s\n", err)
}
// Retrieve user
retrievedUser, err := service.GetUser(1)
if err != nil {
fmt.Printf("Error retrieving user: %s\n", err)
} else {
fmt.Printf("Retrieved user: %+v\n", retrievedUser)
}
// Strategy pattern demo
fmt.Println("\n=== Strategy Pattern ===")
creditCard := CreditCardProcessor{
CardNumber: "1234567890123456",
CVV: "123",
}
paypal := PayPalProcessor{
Email: "user@example.com",
}
orders := []*Order{
{ID: 1, Amount: 99.99, Processor: creditCard},
{ID: 2, Amount: 149.99, Processor: paypal},
}
for _, order := range orders {
order.ProcessPayment()
}
// Observer pattern demo
fmt.Println("\n=== Observer Pattern ===")
eventManager := NewEventManager()
emailNotifier := EmailNotifier{Email: "admin@company.com"}
smsNotifier := SMSNotifier{Phone: "+1-555-0123"}
eventManager.Subscribe(emailNotifier)
eventManager.Subscribe(smsNotifier)
// Trigger events
eventManager.Notify("user_registered", map[string]interface{}{
"username": "new_user",
"email": "new_user@example.com",
})
eventManager.Notify("order_completed", map[string]interface{}{
"order_id": 12345,
"amount": 299.99,
})
}
Debugging Demonstration
Show common struct and interface errors and solutions:
Problem: Method on nil receiver
type Account struct {
Balance float64
}
func (a *Account) Deposit(amount float64) {
a.Balance += amount // Panic if a is nil
}
// Safe approach
func (a *Account) SafeDeposit(amount float64) error {
if a == nil {
return fmt.Errorf("account is nil")
}
a.Balance += amount
return nil
}
Problem: Interface satisfaction confusion
// This won't compile if Rectangle doesn't satisfy Shape
// var shape Shape = Rectangle{Width: 10, Height: 5}
// Check interface satisfaction at compile time
var _ Shape = Rectangle{} // Compile error if Rectangle doesn't satisfy Shape
6. Hands-On Practice (30-40 minutes)
Exercise 1: Library Management System (10 minutes)
Objective: Design a library system using structs and methods
Instructions:
- Create structs for Book, Author, and Library
- Implement methods for borrowing and returning books
- Add validation and error handling
- Track borrowing history
Starter Code:
package main
import (
"fmt"
"time"
)
// TODO: Define Book struct with title, author, ISBN, available status
// TODO: Define Author struct with name, birth year, biography
// TODO: Define Library struct with books collection
// TODO: Implement methods for book operations
func main() {
// Test your library system here
}
Solution:
package main
import (
"fmt"
"time"
)
type Author struct {
Name string
BirthYear int
Biography string
}
func (a Author) String() string {
return fmt.Sprintf("%s (born %d)", a.Name, a.BirthYear)
}
type Book struct {
ID int
Title string
Author Author
ISBN string
Available bool
BorrowedBy string
BorrowedAt time.Time
ReturnedAt time.Time
}
func (b Book) String() string {
status := "Available"
if !b.Available {
status = fmt.Sprintf("Borrowed by %s", b.BorrowedBy)
}
return fmt.Sprintf("%s by %s [%s]", b.Title, b.Author.Name, status)
}
type Library struct {
Name string
Books map[int]*Book
nextID int
}
func NewLibrary(name string) *Library {
return &Library{
Name: name,
Books: make(map[int]*Book),
nextID: 1,
}
}
func (l *Library) AddBook(title string, author Author, isbn string) *Book {
book := &Book{
ID: l.nextID,
Title: title,
Author: author,
ISBN: isbn,
Available: true,
}
l.Books[l.nextID] = book
l.nextID++
return book
}
func (l *Library) BorrowBook(bookID int, borrower string) error {
book, exists := l.Books[bookID]
if !exists {
return fmt.Errorf("book with ID %d not found", bookID)
}
if !book.Available {
return fmt.Errorf("book is already borrowed by %s", book.BorrowedBy)
}
book.Available = false
book.BorrowedBy = borrower
book.BorrowedAt = time.Now()
return nil
}
func (l *Library) ReturnBook(bookID int) error {
book, exists := l.Books[bookID]
if !exists {
return fmt.Errorf("book with ID %d not found", bookID)
}
if book.Available {
return fmt.Errorf("book is not currently borrowed")
}
book.Available = true
book.ReturnedAt = time.Now()
borrower := book.BorrowedBy
book.BorrowedBy = ""
fmt.Printf("Book returned by %s after %v\n", borrower,
book.ReturnedAt.Sub(book.BorrowedAt))
return nil
}
func (l *Library) ListBooks() {
fmt.Printf("Books in %s:\n", l.Name)
for _, book := range l.Books {
fmt.Printf(" %d: %s\n", book.ID, book.String())
}
}
func main() {
library := NewLibrary("City Central Library")
// Add authors
tolkien := Author{
Name: "J.R.R. Tolkien",
BirthYear: 1892,
Biography: "English author and professor",
}
orwell := Author{
Name: "George Orwell",
BirthYear: 1903,
Biography: "English novelist and journalist",
}
// Add books
book1 := library.AddBook("The Hobbit", tolkien, "978-0547928227")
book2 := library.AddBook("1984", orwell, "978-0451524935")
fmt.Printf("Added books:\n")
fmt.Printf(" %s\n", book1)
fmt.Printf(" %s\n", book2)
// List all books
fmt.Println()
library.ListBooks()
// Borrow a book
fmt.Println("\nBorrowing The Hobbit...")
if err := library.BorrowBook(1, "Alice Johnson"); err != nil {
fmt.Printf("Error: %s\n", err)
} else {
fmt.Println("Book borrowed successfully!")
}
// Try to borrow the same book again
fmt.Println("\nTrying to borrow The Hobbit again...")
if err := library.BorrowBook(1, "Bob Smith"); err != nil {
fmt.Printf("Error: %s\n", err)
}
// List books after borrowing
fmt.Println()
library.ListBooks()
// Return the book
fmt.Println("\nReturning The Hobbit...")
if err := library.ReturnBook(1); err != nil {
fmt.Printf("Error: %s\n", err)
} else {
fmt.Println("Book returned successfully!")
}
// Final book list
fmt.Println()
library.ListBooks()
}
Exercise 2: Shape Calculator with Interfaces (15 minutes)
Objective: Create a shape calculation system demonstrating interface polymorphism
Requirements:
- Define a Shape interface with area and perimeter methods
- Implement different shapes (Rectangle, Circle, Triangle)
- Create a ShapeCalculator that works with any Shape
- Add specialized methods for specific shapes
Instructions:
- Design a clean Shape interface
- Implement multiple shape types
- Create functions that work with the interface
- Add type-specific functionality using type assertions
Hints:
- Use math package for geometric calculations
- Consider adding a Name() method to the interface
- Think about validation for shape dimensions
Exercise 3: Plugin System with Interfaces (15 minutes)
Objective: Build a plugin architecture using interfaces
Challenge: Create an extensible system that can load different types of processors
Requirements:
- Define Plugin interface with common methods
- Implement different plugin types (TextProcessor, ImageProcessor, DataProcessor)
- Create a PluginManager to register and execute plugins
- Support plugin discovery and execution
Extension Ideas:
- Add plugin metadata and versioning
- Implement plugin dependencies
- Create a configuration system for plugins
- Add plugin lifecycle management (start, stop, health checks)
7. Common Pitfalls and Troubleshooting (10 minutes)
Frequent Mistakes
-
Problem: Using value receivers when pointer receivers are needed Cause: Not understanding when methods should modify the receiver Solution: Use pointer receivers for modifications or large structs
-
Problem: Interface{} overuse leading to runtime errors Cause: Losing type safety by using empty interfaces everywhere Solution: Use specific interfaces; employ empty interface sparingly
-
Problem: Large interface definitions Cause: Trying to create comprehensive interfaces like other languages Solution: Follow Go’s principle of small, focused interfaces
Debugging Tips
- Use go vet to catch common interface satisfaction issues
- Check nil pointers before calling methods on pointer receivers
- Use type assertions safely with the comma ok idiom
- Print types with
fmt.Printf("%T", variable)when debugging interfaces
Design Best Practices
- Accept interfaces, return concrete types in function signatures
- Keep interfaces small - prefer many small interfaces over few large ones
- Define interfaces where they’re used not where they’re implemented
- Use embedding for composition rather than trying to simulate inheritance
8. Best Practices and Professional Tips (5 minutes)
Industry Standards
- Interface Naming: Use “-er” suffix for single-method interfaces (Reader, Writer, Closer)
- Struct Initialization: Always use named field initialization for public APIs
- Method Receivers: Use pointer receivers for large structs or when modification is needed
- Interface Design: Design interfaces at the boundary where they’re needed
Code Organization
// Good: Small, focused interfaces
type Reader interface {
Read([]byte) (int, error)
}
type Writer interface {
Write([]byte) (int, error)
}
// Good: Interface composition
type ReadWriter interface {
Reader
Writer
}
// Good: Accepting interfaces
func ProcessData(r Reader, w Writer) error {
// Implementation
}
Performance Considerations
- Struct layout: Order fields by size for better memory alignment
- Interface calls: Slight overhead compared to direct method calls
- Embedding depth: Deep embedding can impact performance
- Type assertions: Use sparingly in hot paths
Next Steps in Your Learning Journey
- Concurrency: Learn how interfaces enable concurrent programming patterns
- Testing: Use interfaces for dependency injection and mocking
- Reflection: Understand how Go’s reflection works with interfaces
- Design Patterns: Study how classic patterns apply in Go
9. Summary and Review (5 minutes)
Key Takeaways
- Structs: Custom types that group related data and behavior
- Embedding: Composition mechanism that provides inheritance-like functionality
- Interfaces: Contracts that enable polymorphism and decoupled design
- Implicit Satisfaction: Types automatically satisfy interfaces they implement
Quick Review Questions
- What’s the difference between embedding and regular struct fields?
- When should you use a pointer receiver vs value receiver for methods?
- How does Go’s interface system differ from traditional OOP languages?
- What’s the purpose of the empty interface, and when should you use it?
Answers:
- Embedding promotes fields/methods to the outer type; regular fields require explicit access
- Use pointer receivers for modifications, large structs, or consistency across method sets
- Go uses implicit satisfaction (duck typing) with compile-time checking, no inheritance
- Empty interface holds any type; use sparingly when you truly need to work with any type
For Instructors
Teaching Tips
- Use visual diagrams to show struct memory layout and interface relationships
- Demonstrate composition benefits by comparing with inheritance-based solutions
- Show interface satisfaction at compile time vs runtime
- Emphasize small interfaces and their composition
Assessment Ideas
- Design challenges: Present problems requiring appropriate struct/interface design
- Refactoring exercises: Convert inheritance-based code to Go’s composition model
- Interface satisfaction: Given structs and interfaces, determine which satisfy which
- Real-world modeling: Model complex domains using Go’s type system
Adaptation Notes
- Beginner Focus: Start with simple structs before moving to interfaces
- Advanced Students: Explore interface internals and performance implications
- OOP Background: Help students unlearn inheritance patterns
- Time Management: Interface concepts can be abstract - use concrete examples
Common Student Questions
- “Why doesn’t Go have inheritance?” - Composition is more flexible and clearer
- “When should I use interfaces?” - When you need polymorphism or want to decouple code
- “Can I have optional interface methods?” - No, but you can compose smaller interfaces
- “How do I know if my type satisfies an interface?” - Compiler checks, or use
var _ Interface = Type{}