gnikyt   /  Code ramblings
Ty King

Shopify Value Objects for Go /

An existing project had grown rapidly, resulting in many places where IDs were manually extracted from GIDs or formatted into GIDs without validation and passing the raw values around. To address this, I developed a small internal package with the following goals:

  • Provide a common object for GID types such as Customer, Order, Variant, and Product
  • Validate GID formatting, ensure the GID type matches expectations, and confirm extracted IDs are valid
  • Support conversions between IDs and GIDs in both directions
  • Enable equality checks between objects
  • Allow for marshalling and unmarshalling

Implementation

To start off, I created an interface for the objects and a list of available types

package gid

// ...

// Identifiers represents a set of GID types.
type Identifiers interface {
    CustomerID | ProductID | VariantID | OrderID | InventoryItem
}

// Identifier represents a GID.
type Identifier interface {
    ID() int
    String() string
    Equal(ogid Identifier) bool
    IsValid() bool
}

// ...

Now, Identifier represents a GID and has the following methods:

  • ID() to return the ID inside the GID
  • String() to return the GID string containing the type and ID
  • Equal() to perform equality checks on two objects
  • IsValid() to ensure object is valid

Next, added methods to support bulk conversions of GIDs to IDs or vise-verse:

package go

// ...

// ToIDs converts GIDs to their int representation.
func ToIDs[T Identifiers](gids []T) []int {
    ids := make([]int, len(gids))
    for i, gid := range gids {
        ids[i] = Identifier(gid).ID()
    }
    return ids
}

// ToStrings converts GIDs to their string representation.
func ToStrings[T Identifiers](gids []T) []string {
    strs := make([]string, len(gids))
    for i, gid := range gids {
        strs[i] = Identifier(gid).String()
    }
    return strs
}

// ...

With the foundation established, I started to implement creation methods for the value objects.

package gid

// ...

// typeFrom returns the type name for a given GID type.
func typeFrom[T Identifiers]() string {
    switch any(*new(T)).(type) {
    case CustomerID:
        return "Customer"
    case ProductID:
        return "Product"
    case VariantID:
        return "ProductVariant"
    case OrderID:
        return "Order"
    case InventoryItem:
        return "InventoryItem"
    default:
        return ""
    }
}

// commonNew creates a new GID from a value.
// It supports int64, int, and string formats.
// String formats supported: full GID ("gid://shopify/Type/ID") or numeric string ("123456789").
func commonNew[T Identifiers](val any) (T, error) {
    switch v := val.(type) {
    case int64:
        return T(int(v)), nil
    case int:
        return T(int(v)), nil
    case string:
        parts := strings.Split(v, "/")
        if len(parts) >= 5 {
            // Handle full GID format: "gid://shopify/Type/ID"
            typ := typeFrom[T]()
            if parts[3] != typ {
                return T(0), fmt.Errorf("expected type %s got %s", typ, parts[3])
            }
            cint, err := strconv.ParseInt(parts[4], 10, 64)
            if err != nil {
                return T(0), fmt.Errorf("invalid ID in GID: %s", v)
            }
            return T(int(cint)), nil
        } else {
            // Handle numeric string format: "123456789"
            cint, err := strconv.ParseInt(v, 10, 64)
            if err != nil {
                return T(0), fmt.Errorf("invalid GID format or numeric ID: %v", v)
            }
            return T(int(cint)), nil
        }
    default:
        return T(0), fmt.Errorf("unsupported type for GID: %T", val)
    }
}

// New creates a new GID from a value.
// It supports int64, int, and string formats.
// For strings, it expects the format "gid://shopify/Type/ID" or a
// numeric "28282823332".
// If the value is not recognized, it returns a zero value of the type.
// It ignores errors in validation, for validation use NewValidated.
func New[T Identifiers](val any) T {
    n, _ := commonNew[T](val)
    return n
}

// NewValidated creates a new GID from a value and validates it.
// It returns an error if the GID is not valid.
func NewValidated[T Identifiers](val any) (T, error) {
    gid, err := commonNew[T](val)
    if err != nil {
        return gid, err
    }
    if !Identifier(gid).IsValid() {
        return gid, fmt.Errorf("invalid %T value: %v", gid, val)
    }
    return gid, nil
}

// ...
  • commonNew is a common method used by both New and NewValidated to create a value object
  • typeFor is a method to determine the expected type portion of the GID
  • New is a generic method for creating a value object of a type, without validation
  • NewValidated is a generic method for creating a value object of a type, with validation

Example implementation

An example implementation utilizing the Identifier interface for a value object:

package gid

// CustomerID represents a GID for a Shopify Customer.
type CustomerID int

func (cid CustomerID) ID() int {
    return int(cid)
}

func (cid CustomerID) String() string {
    return fmt.Sprintf("gid://shopify/Customer/%d", cid)
}

func (cid CustomerID) Equal(ocid Identifier) bool {
    if other, ok := ocid.(CustomerID); ok {
        return cid == other
    }
    return false
}

func (cid CustomerID) MarshalJSON() ([]byte, error) {
    return json.Marshal(cid.String())
}

func (cid *CustomerID) UnmarshalJSON(data []byte) error {
    var gid string
    if err := json.Unmarshal(data, &gid); err != nil {
        return err
    }
    *cid = New[CustomerID](gid)
    return nil
}

func (cid CustomerID) IsValid() bool {
    return cid.ID() > 0
}

// CustomerIDs represents a slice of CustomerID.
type CustomerIDs []CustomerID

func (cids CustomerIDs) ToIDs() []int {
    return ToIDs(cids)
}

func (cids CustomerIDs) ToStrings() []string {
    return ToStrings(cids)
}

// NewCustomerID creates a new CustomerID from a value.
func NewCustomerID(val any) CustomerID {
    return New[CustomerID](val)
}

// NewCustomerIDValidated creates a new CustomerID from a value and validates it.
func NewCustomerIDValidated(val any) (CustomerID, error) {
    return NewValidated[CustomerID](val)
}

API

Init without validation

How to create and access information of the object.

These methods do not return an error for bad objects, it will return an object with a zero value instead. If you need validation, refer to validation section below this.

cid := gid.NewCustomerID("gid://shopify/Customer/12345") // accepts GID or ID
cid.ID() // 12345
cid.String() // gid://shopify/Customer/12345
cid.IsValid() // true

oid := gid.NewOrderID(478848)
oid.ID() // 478848
oid.String() // gid://shopify/Order/478848
oid.IsValid() // true

// Alternatively, by generic method.
pid := gid.New[gid.ProductID](123) // gid.New[gid.ProductID]("gid://shopify/Product/123")

// No error reported, but zero-value object returned.
vid := gid.NewVariantID("gid://shopify/Whoops/1234")
vid.IsValid() // false

Init with validation

All New{X}ID methods support validation by appending Validated.

These methods will return an error and the object will be zero value.

cid, err := gid.NewCustomerIDValidated("whoops")
// err = "invalid CustomerID: whoops"
cid.IsValid() // false

Comparisons

How to compare two objects.

cid := gid.NewCustomerID("gid://shopify/Customer/12345")
cid2 := gid.NewCustomerID("gid://shopify/Customer/123456")
oid := gid.NewCustomerID("gid://shopify/Customer/123456")
fmt.Printf("Same? %v", cid.Equal(cid2)) // Same? false
fmt.Printf("Same? %v", cid.Equal(cid)) // Same? true
fmt.Printf("Same? %v", oid.Equal(cid)) // Same? false

Slice supports

All built-in objects also have slices such as ProductIDs, OrderIDs, etc. to do things like convert all objects to their IDs or all objects to their GID.

pids := gid.ProductIDs{
    gid.ProductID(1),
    gid.ProductID(2),
    gid.NewProductID("gid://shopify/Product/5"),
}
pids.ToIDs() // [1, 2, 5]
pids.ToStrings() // [gid://shopify/Product/1, gid://shopify/Product/2, gid://shopify/Product/5]

// Generic versions.
gid.ToIDs([]gid.CustomerID{
    gid.CustomerID(1),
    gid.NewCustomerID("gid://shopify/Customer/5"),
}) // [1, 5] 
gid.ToStrings([]gid.CustomerID{
    gid.CustomerID(1),
    gid.NewCustomerID("gid://shopify/Customer/5"),
})  // [gid://shopify/Customer/1, gid://shopify/Customer/5]

Marshalling/Unmarshalling

Automatic support for marshalling to JSON and from a struct into the value object type.

type something struct {
   OrderID gid.OrderID `json:"order_id"`
   // ...
}
  • If Unmarshalled, order_id will be cased to a OrderID value object.
  • If Marshalled, order_id will be turned into gid://shopify/Order/{id}.

You can pass around the value objects and utilize them too:

func SomeFunc(open bool, variantID gid.VariantID) {
    // ...
}
sm := SomeFunc(true, gid.VariantID(129292))

You can view the package snippet here on Github.

Anchors
Appendix

Copyright under CC-4.0.

Available in the following alternative formats: MD  /  TXT  /  PDF