Go Concepts
Interface
An interface in go defines a set of method signatures. If a type implements all those methods, it satisfies the interface and no explicit declaration is needed. Example:
package main
import (
"fmt"
)
type Speaker interface {
Speak() string
}
type Simulator struct {}
type Human struct {
Question string
}
func (h Human) Speak() string {
return h.Question
}
func (s Simulator) Speak() string {
return "Just enjoy the game"
}
func MakeItTalk(s Speaker) string {
return s.Speak()
}
func main() {
h := Human{"Why are we here?"}
s := Simulator{}
hQuestion := MakeItTalk(h)
sAnswer := MakeItTalk(s)
response := fmt.Sprintf("Human asks: %s\nSimulator responds: %s\n", hQuestion, sAnswer)
fmt.Println(response)
}
- any type that has a Speak() string method is a Speaker
- both Human and Simulator types satisfy the Speaker interface by implementing the Speak() string method
- MakeItTalk takes any Speaker, calls .Speak() and returns the result. Works with both Human and Simulator thanks to interface
- fmt.Sprintf returns the formatted string, %s is a placeholder for a string
Back to http.ResponseWriter. It defines methods like:
- Header() http.Header
w.Header().Set("Content-Type", "application/json")
- WriteHeader(statusCode int)
w.WriteHeader(http.StatusNotFound) // 404
w.WriteHeader(http.StatusOK) // 200
w.WriteHeader(http.StatusInternalServerError) // 500
w.WriteHeader(http.StatusBadRequest) // 400
w.WriteHeader(http.StatusUnauthorized) // 401
- Write([]byte) (int, error) which would same as io.WriteString(w, "text")
w.Write([]byte("Hello, world"))
So any type that implements these can be used as an http.ResponseWriter.
Goroutines
Goroutines are a lightweight thread managed by the Go runtime. It starts with the go
keyword which spins it off in a new goroutine.
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from goroutine")
}
func main() {
go sayHello()
fmt.Println("Hello from main")
time.Sleep(1 * time.Second)
}
Output:
Hello from main
Hello from goroutine
This is a typical result, but the order is not guaranteed because goroutines are scheduled concurrently by the Go runtime.
If the main function ends, all goroutines are killed even if they haven't finished. Here I am using time.Sleep for now to prevent that.
Thousands of these goroutines can be run and the Go runtime schedules them effciently using its own scheduler.
Goroutines are background workers that do their job once the scheduler gives them a chance.
Channels
Goroutines don't share data by default, they just run independently. To coordinate them or pass data between them safely, Go uses channels.
A channel is typed conduit for communication between goroutines. It allows goroutines to send data to one another in a thread-safe, synchronised way.
package main
import (
"fmt"
)
func main() {
ch := make(chan string)
go func() {
ch <- "hello from goroutine"
}()
msg := <-ch
fmt.Println(msg)
}
In Go, every program starts with a single goroutine, the one running the main() function. The main goroutine waits (<-ch
) until
the anonymous goroutine sends a message.
The anonymous goroutine waits (ch <-
) until the main goroutine is ready to receive.
Sending and receiving is synchronous, both sides have to be present at the same time if the channel is unbuffered (can't store anything on its own).
So a channel is created (ch := make(chan string)
), then a goroutine is started that tries to send a string to the channel (ch <- "hello from goroutine"
).
Since the channel is unbuffered, the send cannot complete until someone receives it. The main goroutine receives the value (msg := <-ch
). This exchange acts like a handshake, where both sides must be ready for the transfer to complete.
This synchronisation makes channels powerful, they help you avoid race conditions without using mutexes or locks.
A mutex (short for mutual exclusion) ensures that only one goroutine can access a critical section of code or shared data at a time. It prevents race conditions by enforcing exclusive access.
Unbufferred vs buffered channels
By default, channels are unbuffered, which means both sender and receiver must be ready at the same time.
A buffered channel allows the sender to continue execution without blocking, until the buffer fills up.
ch := make(chan int, 2) // buffer size of 2
ch <- 1
ch <- 2
// ch <- 3 // would block unless something is read
Context
The context package provides structured, idiomatic way to control goroutines and channels including cancellations, timeouts, and passing request-level data. Especially in HTTP servers, API calls, and microservices.
context.Context
is an interface.
context.Background()
→ base context (root)context.WithCancel(ctx)
context.WithTimeout(ctx, duration)
context.WithValue(ctx, key, val)
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
fmt.Println("Goroutine canceled!")
return
default:
fmt.Println("Working...")
time.Sleep(300 * time.Millisecond)
}
}
}()
time.Sleep(1 * time.Second)
cancel() // tell the goroutine to stop
time.Sleep(300 * time.Millisecond) // give it time to print cancellation
}
Output:
Working...
Working...
Working...
Goroutine canceled!
context.WithCancel(parent)
returns a new context and a cancel()
function.
select
is a control structure like switch but designed to specifically work with channels.
When cancel()
is called, all goroutines listening to ctx.Done()
will immediately receive a signal and can exit gracefully.
ctx.Done()
returns a channel: <-chan struct{}
this is the channel that gets closed when the context is cancelled or times out.
struct{}
is an empty struct, it literally takes 0 bytes in memory. It is used when you want to signal an event or notification without sending any actual data.
So <-ctx.Done()
means block here until the context is cancelled then continue.
Timeout can also be added:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
This cancels the context automatically after 2 secs.
defer
is a keyword that delays the execution of a function until the surrounding function returns.
Use cases:
- Cancel API calls when the user disconnects or a timeout is hit
- Limit a goroutine lifetime (avoid leaks)
- Pass a request ID through layers for logging/tracing
- Set a deadline for DB queries, HTTP calls, or background jobs
JSON marshaling and unmarshaling
Marshaling is converting a Go value to JSON (goStruct -> jsonData).
Unmarshaling converts JSON to a Go value (jsonData -> goStruct).
Both are done using the encoding/json standard package.
package main
import (
"fmt"
"encoding/json"
)
type Person struct {
Name string `json:"name"`
PetNames []string `json:"pet_names"`
}
func main() {
p := Person{Name: "Reem", PetNames: []string{"Poppy", "Lily"}}
// Marshal struct into JSON
jsonBytes, err := json.Marshal(p)
if err != nil {
fmt.Println("Error marshaling:", err)
return
}
fmt.Println("Marshaled JSON:", string(jsonBytes))
// Umarshal it back to struct
var decoded Person
err = json.Unmarshal(jsonBytes, &decoded)
if (err != nil) {
fmt.Println("Error unmarshaling:", err)
return
}
fmt.Println("Decoded Struct:", decoded)
}
Output:
Marshaled JSON: {"name":"Reem","pet_names":["Poppy","Lily"]}
Decoded Struct: {Reem [Poppy Lily]}
First a Person struct is created.
Then it is marshaled into JSON bytes (which could be sent to an API or saved).
Then it is unmarshaled by:
- declaring a variable of type Person, its empty at this point, all fields are zero value
- using the function
json.Unmarshal
which takes two arguments: a slice of bytes that contain JSON and a pointer to decoded (a pointer required so that Unmarshal can access and modify the actual memory where decoded lives)
This "serialising" and "deserialising" helps data to be sent over the network, saved to disks, passed between systems and stored to databases.
This is akin to json.dump
/json.load
in Python and JSON.stringify
/JSON.parse
in JS.
But here are some advantages in Go:
- Static typing = more safety. Go validates the data against your struct
- Go is compiled and optimised so JSON handling is very fast and memory-efficient
- Explicit field mapping via tags gives you fine-grained control over how Go struct fields are represented in JSON. For example:
type Coin struct {
Name string `json:"name"`
Price float64 `json:"-"` // Omit completely
Symbol string `json:"symbol,omitempty"` // Omit if empty
}
-
Deterministic and predictable behaviour makes Go less forgiving but more robust as Go does not guess or coerce types. Eg. if price was a string in JSON, an error would be thrown
-
Supports streaming and decoding large JSON docs without loading the entire thing into memory using the Reader interface
These are some of the core concepts in Go, it was designed to be simple and readable.
Until next time ✌️