Oo the r/golang forum on Reddit user u/Nottymak88 posted his code and asked for help "Assigning values into Nested structs".
That is such a common question for those learning Go I decided to answer it with the repository giving two (2) different approaches.
But first, let's start with some general tips based on their code.
Here are the things that came to mind while reading the asker's code. They are not arranged in perfect order, but at least I tried. A bit.
- Don't Try to Eat the Entire Apple — Instead of trying to assemble a large struct with many embedded structs, treat each struct as its own entity, complete with methods to enable interaction. This approach allows for divide and conquer and reduces the complexity one needs to manage in ones head all at once.
- Use Instantiators — With Go we can instantiate an object using itsname and braces — e.g.
Policy{}
— but experience tells me you are almost always better off defining at least one instantiator function, typically prefixed withNew
, e.g.NewPolicy()
. - Use Pointers — Usually it is better to use pointers — such as when returning from
New*()
intantiator funcs and when creating slice types like[]*Coverage
vs[]Coverge
. Not using pointer ends up creating many small frictions, and it is easier to just use pointers. One example is recursively defined structs must use pointers, and another example is an interface cannot call a func with a pointer receiver if the object is not a pointer. However, if you do have a valid reason for not using a pointer — such as minimizing heap allocations and garbage collection for edge-case apps or you have the discipline to code Go in a purely functional, non-mutable approach, which by the way is not idiomatic Go — then by all means return a stack-based value. - It's Good to String Along Your Types — If you give each of your types a
String()
property then passing an instance of the type tofmt.Println()
or any of the similar print/sprint methods will automatically call the type'sString()
method. This simplifies composing log messages, error messages and other human-readable output. Actually, this is just one use-case of theStringer
interface being satisfied by having aString()
method, but interfaces are way out of scope for this discussion. - Give
main()
a package of its own — Create a package of its own formain()
and put in the cmd directory, as the Go team recommends. This means yourmain()
code will need to use package references to call your other code. This can help leads to developing applications as a thin executable veneer around everything else that are reusable packages, which IMO is a best practice. - Be pithy when naming your reusable package — Packages names need to complete globally (within a Go application) so name your package something short, pithy, and unlikely to be used by another package and/or a variable name. As an aside, I curse the Go team for naming the URL package
net/url
thereby squatting on the perfect variable name for a URL, e.g.url
and a name that every project I have every worked on it filled with variables of that name thereby making an IDE constantly complain when I read their code. But I digress. - Give Each Type a File — Although not literally every time, but it makes good sense to separate out your code into multiple files so it is easier to wrap your head around what code is where, and also it gives you room to elaborate on each type as you will almost certainly do so when you are writing a real-world application.
- Give Each Property a Line — Don't list multiple properties on the same line in a struct. Sure it may eliminate duplication of the type but doing so can make a struct exceedingly hard to read, especially for properties and types that end up with 20, 30, 40, 50 or more characters of whitespace between the name and the type. Also, you can't add property tags to properties — such as for JSON — when multiple properties are combined on a line.
- Create Plural Types — When you want to use a slice of a struct, such as
[]Location
— or as I suggested[]*Location
– then go ahead and create a plural type, such asLocations
. Doing so will allow you to create methods for that type when you realize that's the best way to minimize unnecessary code duplication — vs. acceptable code duplication — and, you will thank me later. - Use Adder Functions — When you have a struct with a property whose type is a slice of objects, write a method to add those objects to that object. It makes the code much more readable, encapsulates the code that does the adding which ensures it gets called correctly and added correctly, and reduces more unnecessary code duplication.
- Default to Private — With Go, struct properties are package-private if they start with lower-case. As a rule of thumb it is better to start with all properties being private and then either expose the ones that needs to be public on an as-needed basis, or better, create methods to access those properties. The latter approach is often preferred in Go because methods can participate in interfaces but properties cannot, and the more complex a project becomes the more likely it will need to use interfaces to resolve cyclical dependencies, among several other benefits of using interfaces.
- Avoid "Or" in type names — This may just be an opinionated persoanl best practice, but using
PersonOrOrg
when you could useInsured
seems like it is asking for complex naming in methods that will be needed.
Approach A is generally my preferred approach and it uses a bespoke options struct for each entity struct's optional properties.
Here is what that looks like for Policy
, which I derived from the asker's original code:
type Policy struct {
number string
effectiveDate time.Time
expirationDate time.Time
lines Lines
transactions Transactions
}
type PolicyOpts struct {
EffectiveDate time.Time
ExpirationDate time.Time
}
func NewPolicy(number string, opts *PolicyOpts) *Policy {
return &Policy{
number: number,
effectiveDate: opts.EffectiveDate,
expirationDate: opts.ExpirationDate,
lines: make([]*Line, 0),
transactions: make([]*Transaction, 0),
}
}
func (p *Policy) AddLine(line *Line) *Policy {
p.lines = append(p.lines, line)
return p
}
func (p *Policy) AddTransaction(tx *Transaction) *Policy {
p.transactions = append(p.transactions, tx)
return p
}
The above can then be used like so:
func main() {
policy := NewPolicy("Policy1", &PolicyOpts{
EffectiveDate: now,
ExpirationDate: addYear(now),
})
fmt.Printf("%s\n", policy)
However, here is the example the asker wanted to encode into embedded structures, which I embellished a bit by adding a few more values to his instantiation request:
package main
import (
"fmt"
"time"
"github.com/shopspring/decimal"
"insure"
)
func main() {
now := time.Now()
/*
var P Policy
P.Number = "Policy1"
P.line[0].ID = "Line1"
P.line[1].ID = "Line2"
P.transaction[0].ID = "Transaction1"
P.line[0].coverages[0].Indicator = true
P.line[0].Risks[0].ID = "Risk1"
P.line[0].Risks[1].ID = "Risk1"
P.line[0].Risks[0].coverages[0].Indicator = true
P.line[0].Risks[1].coverages[0].Indicator = true
P.line[0].loc[0].Address1 = "Addr1"
*/
policy := insure.NewPolicy("Policy1", &insure.PolicyOpts{
EffectiveDate: now,
ExpirationDate: addYear(now),
}).
AddTransaction(insure.NewTransaction("Transaction1")).
AddLine(insure.NewLine("Line1", &insure.LineOpts{
TypeLOB: insure.AutoLOBType,
TermPremium: decimal.NewFromInt(120),
PriorTermPremium: decimal.NewFromInt(110),
}).
AddCoverage(insure.NewCoverage(true)).
AddRisk(insure.NewRisk("Risk1", &insure.RiskOpts{
Included: true,
}).AddCoverage(insure.NewCoverage(true))).
AddRisk(insure.NewRisk("Risk2", &insure.RiskOpts{
Included: true,
}).AddCoverage(insure.NewCoverage(true))).
AddLocation(insure.NewLocation("Addr1")),
).
AddLine(insure.NewLine("Line2", &insure.LineOpts{}))
fmt.Printf("%s\n", policy)
}
func addYear(t time.Time) time.Time {
return t.AddDate(1, 0, 0)
}
// shortFormTime is a format.
// To understand why 2006-01-02, see https://stackoverflow.com/a/52966197/102699
const shortFormTime = "2006-01-02"
You can see the complete code for Approach A here.
Note, the complete code is not a fully fleshed out app; it just illustrates the points mentioned here but does not try to go farther.
Approach B is a variant of an approach that AFAIK was first proposed by Dave Cheney which he called "Functional options for Friendly APIs." Dave Calhoun has covered it as well as many others since Dave, too. The proposed v2 of the standard encoding/json
package also chose this approach.
Here is our Approach B for Policy
when using option funcs (as compared to Approach A with option structs above). Note how the Set*()
and Add*()
methods return a closure that gets executed in NewPolicy()
:
type Policy struct {
number string //GUID
effectiveDate time.Time
expirationDate time.Time
lines Lines
transactions Transactions
}
type PolicyOptions func(*Policy)
func NewPolicy(number string, opts ...PolicyOptions) *Policy {
p := &Policy{
number: number,
lines: make([]*Line, 0),
transactions: make([]*Transaction, 0),
}
for _, opt := range opts {
opt(p)
}
return p
}
func (PolicyOptions) SetEffectiveDate(d time.Time) PolicyOptions {
return func(p *Policy) {
p.effectiveDate = d
}
}
func (PolicyOptions) SetExpirationDate(d time.Time) PolicyOptions {
return func(p *Policy) {
p.expirationDate = d
}
}
func (PolicyOptions) AddLine(line *Line) PolicyOptions {
return func(p *Policy) {
p.lines = append(p.lines, line)
}
}
func (PolicyOptions) AddTransaction(tx *Transaction) PolicyOptions {
return func(p *Policy) {
p.transactions = append(p.transactions, tx)
}
}
You can use the above in an example equivalent to the one shown for Approach A, like so:
func main{
var po insure.PolicyOptions
policy := insure.NewPolicy("Policy1",
po.SetEffectiveDate(now),
po.SetExpirationDate(addYear(now)),
)
fmt.Printf("%s\n", policy)
}
Then we have the full the example the asker wanted to encode into embedded structures, which I also embellished the same amount here:
package main
import (
"fmt"
"time"
"github.com/shopspring/decimal"
"insure"
)
// shortFormTime is a format.
// To understand why 2006-01-02, see https://stackoverflow.com/a/52966197/102699
const shortFormTime = "2006-01-02"
func main() {
now := time.Now()
var po insure.PolicyOptions
var lo insure.LineOptions
var ro insure.RiskOptions
/*
var P Policy
P.Number = "Policy1"
P.line[0].ID = "Line1"
P.line[1].ID = "Line2"
P.transaction[0].ID = "Transaction1"
P.line[0].coverages[0].Indicator = true
P.line[0].Risks[0].ID = "Risk1"
P.line[0].Risks[1].ID = "Risk1"
P.line[0].Risks[0].coverages[0].Indicator = true
P.line[0].Risks[1].coverages[0].Indicator = true
P.line[0].loc[0].Address1 = "Addr1"
*/
policy := insure.NewPolicy("Policy1",
po.SetEffectiveDate(now),
po.SetExpirationDate(addYear(now)),
po.AddTransaction(insure.NewTransaction("Transaction1")),
po.AddLine(
insure.NewLine("Line1",
lo.SetTypeLOB(insure.AutoLOBType),
lo.SetTermPremium(decimal.NewFromInt(120)),
lo.SetPriorTermPremium(decimal.NewFromInt(110)),
lo.AddCoverage(insure.NewCoverage(true)),
lo.AddRisk(insure.NewRisk("Risk1",
ro.AddCoverage(insure.NewCoverage(true)),
)),
lo.AddRisk(insure.NewRisk("Risk2",
ro.AddCoverage(insure.NewCoverage(true)),
)),
lo.AddLocation(insure.NewLocation("Addr1")),
),
),
po.AddLine(insure.NewLine("Line2")),
)
fmt.Printf("%s\n", policy)
}
func addYear(t time.Time) time.Time {
return t.AddDate(1, 0, 0)
}
You can see the complete code for Approach B here.
Note, this code also is notnot a fully fleshed out; it also just illustrates the points mentioned here but does not try to go farther.
So you can visualize the output of the two different approaches, here is what you'll see from both them:
Policy:
Number: Policy1
Effective Date: 2023-11-03
Expiration Date: 2024-11-03
Lines:
Line: Line1
Coverages:
Coverage: true
Risks:
Risk ID: Risk1
Coverages:
Coverage: true
Risk ID: Risk2
Coverages:
Coverage: true
Locations:
Location: Addr1
Line: Line2
Coverages:
Risks:
Locations:
Transactions:
Transaction ID: Transaction1
Note: the output does not show all values our code sets because I did not go back any update my String()
after I embellished the initialization code with a little extra data.
There are many differnt ways to skin a cat — no offence to felines, or to those who adore them — and these are just two approaches to object creation in Go.
Ask any other Go programmer, and they are certain to have either small tweaks to what I have shown all the way up to major disagreements. 🤷
But the reality is these approaches work and while no approach is perfect these two approaches can provide developers new to Go with a starting point for learning how to create embedded structures in Go and learning how to overall improve their craft.
Besides, aren't programmer disagreements what makes the world go round? 🙂