DEV Community

Matthias Friedrich
Matthias Friedrich

Posted on

Part 2: Mastering Service Registration in Go with Parsley

The Challenge of Scalable Service Wiring

In the previous article, Part 1: Mastering Dependency Injection in Go: A Quick Start Guide, we explored how Parsley simplifies the instantiation of services. However, as an application grows, the dependency graph becomes more intricate. You no longer just have standalone services; you have services that depend on database pools, configuration providers, and external API clients.

Manually wiring these dependencies in a factory function is error-prone. If a constructor requires five different dependencies, you must ensure each is correctly initialized and passed in the right order. Furthermore, handling errors during this initialization often leads to nested if statements that clutter your startup logic.

Introducing Parsley’s Registration System

Parsley’s registration system is designed to handle this complexity by leveraging Go’s reflection capabilities. Instead of you calling the constructor, you tell Parsley about the constructor. Parsley then takes responsibility for resolving the arguments and invoking the function.

This approach centers around two primary registration methods:

  1. Constructor Functions: For services that Parsley should instantiate.
  2. Pre-existing Instances: For objects already created outside the container.

Architecture: How Parsley Sees Your Services

When you register a function, Parsley inspects its signature at runtime. It identifies the return type as the service being provided and the input parameters as the required dependencies.

Constructor Function Signatures

Parsley supports several idiomatic Go patterns for constructors:

  • Standard: func(...) T
  • Error-aware: func(...) (T, error) — Parsley propagates the error during resolution if the constructor fails.
  • Context-aware: func(context.Context, ...) (T, error) — Useful for accessing the resolution context or handling initialization timeouts.

Note: When using context.Context, it must be the first parameter in the function signature.

Practical Example: Wiring a Data Layer

Let's look at a realistic scenario where a UserService depends on a Repository.

1. Define the Components

We define the contracts as interfaces. This allows us to swap implementations (e.g., switching from a SQL database to a mock for testing) without changing the business logic.

type Repository interface {
    FindUser(id int) (*User, error)
}

type UserService interface {
    GetProfile(id int) (*Profile, error)
}

type userService struct {
    repo Repository
}

func NewUserService(repo Repository) UserService {
    return &userService{repo: repo}
}
Enter fullscreen mode Exit fullscreen mode

2. Registering the Services

You register these services with the ServiceRegistry. Parsley automatically detects that NewUserService requires an implementation of Repository and will resolve it before calling the constructor.

package main

import (
    "github.com/matzefriedrich/parsley/pkg/registration"
)

func main() {
    registry := registration.NewServiceRegistry()

    // Register the repository implementation (assuming NewSqlRepository exists)
    _ = registration.RegisterTransient(registry, NewSqlRepository)

    // Register the service; Parsley resolves the repository automatically
    _ = registration.RegisterTransient(registry, NewUserService)

    // ... continue with resolving and using services ...
}
Enter fullscreen mode Exit fullscreen mode

Registering Pre-existing Instances

There are cases where you cannot use a simple constructor function. For example, when you have a third-party client that requires complex configuration or a legacy object that is already initialized elsewhere. A common use case is registering a database connection pool that was initialized at application startup.

For these scenarios, Parsley provides the RegisterInstance method.

// Register a pre-configured *sql.DB instance as a singleton
db, _ := sql.Open("postgres", connStr)
_ = registration.RegisterInstance[*sql.DB](registry, db)
Enter fullscreen mode Exit fullscreen mode

Warning: Any service registered via RegisterInstance is treated as a Singleton. The same instance is reused throughout the application, maintaining its state and consistency.

We will revisit this database example in a later part of this series when we discuss Factory Functions, which provide a more flexible way to handle complex initializations within the container.

Operational Considerations

Interface-Driven Design

Parsley works most effectively when you register services against interfaces. This promotes decoupling and makes your application easier to test. While you can register concrete types, doing so ties your components to specific implementations.

Error Handling

Parsley encourages explicit error handling. If a constructor returns an error, Parsley will catch it during the resolution process and return it to the caller. This ensures that your application doesn't start with partially initialized or broken services.

Validation

To build confidence in your configuration, Parsley includes built-in validation for service registrations. This feature helps prevent runtime errors caused by missing dependencies or circular references by verifying the entire dependency graph during startup.

Summary

Understanding service registration is the first step toward mastering Parsley. By using constructor functions, you delegate the complexity of dependency wiring to the framework. For more complex integration needs, manual instance registration provides a reliable escape hatch.

In the next part of this series, we will explore Understanding Lifetimes and Scopes, where we discuss how to manage the lifecycle of your services effectively.

Next Steps

Top comments (0)