Imagine receiving a request that sounds straightforward:
“Could you replace SendGrid with AWS SES?”
At first, it seems like a simple matter of swapping one email library for another. But once you open the codebase, you discover that SendGridMailer is instantiated directly inside the registration service, password recovery service, order service, and even the worker that sends daily reports.
After updating all those places, you run the tests and start feeling uneasy. Some tests are still using real clients. A checkout test might call the real payment gateway, send a real email, or depend on a collection of environment variables.
This is a familiar symptom of tight coupling: components in the system depend too heavily on specific implementations.
In this article, we will gradually remove those dependencies using:
- Interfaces
- The Dependency Inversion Principle
- Dependency Injection
- Manual wiring
- An IoC Container with Uber Fx
The goal is not to turn every struct into an interface. The goal is to ensure that business logic does not need to change every time the infrastructure changes.
Coupling is a broad topic. A system can be coupled through its database, message broker, communication protocol, schema, or service boundaries. This article focuses specifically on dependencies between components inside a Go application.
What Does Tight Coupling Look Like?
Suppose we have a checkout service that needs to charge a payment and send a confirmation email:
package order
import (
"context"
"fmt"
"os"
)
type OrderService struct {
payment *StripePayment
mailer *SendGridMailer
}
func NewOrderService() *OrderService {
return &OrderService{
payment: NewStripePayment(os.Getenv("STRIPE_KEY")),
mailer: NewSendGridMailer(os.Getenv("SENDGRID_KEY")),
}
}
func (s *OrderService) Checkout(
ctx context.Context,
orderID string,
amount int64,
email string,
) error {
if err := s.payment.Charge(ctx, amount); err != nil {
return err
}
body := fmt.Sprintf("Order %s has been paid", orderID)
return s.mailer.Send(ctx, email, body)
}
At first glance, there does not seem to be anything obviously wrong with this code. The service has all the dependencies it needs and can work immediately after construction.
The problem is that OrderService is making several decisions by itself:
- It decides to use Stripe as the payment gateway.
- It decides to use SendGrid as the email provider.
- It decides where the API keys come from.
- It decides how those clients are constructed.
However, its primary responsibility should only be handling the checkout business flow.
By constructing its own dependencies, StripePayment and SendGridMailer become hard dependencies of OrderService.
First consequence: changing infrastructure requires changing business logic
To replace Stripe with Paypal, you must open OrderService and modify its constructor.
To replace SendGrid with SES, you must modify OrderService again.
The order business logic is now involved in decisions that belong to the infrastructure layer.
Second consequence: unit tests become difficult to control
Because OrderService constructs Stripe and SendGrid by itself, tests have no convenient way to provide fake implementations.
You could introduce flags, patch global variables, or create a separate collection of environment variables for tests. However, the more you do this, the harder the tests become to understand, and the more they depend on external conditions.
A Package-Level Singleton Does Not Solve the Problem
Another common approach is to move the dependency into a global variable:
package mailer
import "os"
var DefaultMailer = NewSendGridMailer(os.Getenv("SENDGRID_KEY"))
Then use it elsewhere:
package order
import "myapp/mailer"
func (s *OrderService) Checkout(...) error {
return mailer.DefaultMailer.Send(...)
}
OrderService no longer calls NewSendGridMailer directly, but the dependency is still fixed through global state.
A test that wants to replace the mailer must modify mailer.DefaultMailer and then remember to restore the original value. If several tests run in parallel, they may interfere with each other and produce failures that are difficult to reproduce.
In other words, moving a dependency from a constructor into a global variable only changes the location of the coupling. It does not remove it.
An Intermediate Step: Inversion of Control with the Factory Pattern
IoC (Inversion of Control) is a design principle with one core idea: a class should not control the creation of its own dependencies — invert that control and push it outside.
Picture it this way: a restaurant chef doesn't grow their own vegetables or raise their own cattle. Ingredients are delivered to the kitchen by the procurement team, and the chef just focuses on cooking. If the chef also had to farm, then every change of meat supplier would mean... retraining the chef. Sounds absurd, right? Well, OrderService calling new StripePayment(...) is exactly that chef raising his own cattle.
A simple improvement is to separate object construction from the service:
package payment
import "os"
func NewDefaultPayment() *StripePayment {
return NewStripePayment(os.Getenv("STRIPE_KEY"))
}
OrderService then uses the factory:
type OrderService struct {
payment *payment.StripePayment
}
func NewOrderService() *OrderService {
return &OrderService{
payment: payment.NewDefaultPayment(),
}
}
This centralizes the logic for reading configuration and constructing the Stripe client. If the construction process changes, we only need to update the factory.
This is an improvement, but it is not yet loose coupling.
OrderService still depends on:
- The concrete type
*payment.StripePayment - The
paymentpackage - The
payment.NewDefaultPaymentfunction
Replacing Stripe with Paypal may still require changing OrderService. A factory centralizes object construction, but it does not remove the dependency on a concrete implementation. To solve that problem, we need the Dependency Inversion Principle.
Now, let’s refactor the code above to achieve loose coupling:
First: Apply Dependency Inversion Principle
The Dependency Inversion Principle, commonly abbreviated as DIP, is the D in SOLID.
The principle has two main ideas:
- High-level modules should not depend directly on low-level modules. Both should depend on abstractions.
- Abstractions should not depend on implementation details. Implementations should conform to abstractions.
In our example:
OrderServiceis the high-level module because it contains business logic.StripePaymentandSendGridMailerare infrastructure implementations.
OrderService does not really need to know what Stripe or SendGrid is. It only needs two capabilities:
- Something that can charge a payment
- Something that can send an email
These capabilities can be represented by small interfaces:
package order
import "context"
type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}
type Mailer interface {
Send(ctx context.Context, to string, body string) error
}
The Stripe implementation:
package payment
import "context"
type StripePayment struct {
apiKey string
}
func NewStripePayment(apiKey string) *StripePayment {
return &StripePayment{
apiKey: apiKey,
}
}
func (p *StripePayment) Charge(
ctx context.Context,
amount int64,
) error {
// Call the Stripe API.
return nil
}
The Paypal implementation:
package payment
import "context"
type PaypalPayment struct {
partnerCode string
}
func NewPaypalPayment(partnerCode string) *PaypalPayment {
return &PaypalPayment{
partnerCode: partnerCode,
}
}
func (p *PaypalPayment) Charge(
ctx context.Context,
amount int64,
) error {
// Call the Paypal API.
return nil
}
Go uses implicit interface implementation. StripePayment and PaypalPayment do not need to declare that they implement PaymentGateway.
As long as they provide the required method:
Charge(context.Context, int64) error
they satisfy the interface.
OrderService can now depend on abstractions:
type OrderService struct {
payment PaymentGateway
mailer Mailer
}
The direction of the dependencies has changed:
Before:
OrderService ───────> StripePayment
OrderService ───────> SendGridMailer
After:
OrderService ───────> PaymentGateway <────── StripePayment
^───────── PaypalPayment
OrderService ───────> Mailer <────────────── SendGridMailer
^───────────── SESMailer
OrderService no longer depends directly on Stripe or SendGrid. The concrete implementations must conform to the contracts required by OrderService.
However, changing the fields to interfaces is not enough.
If the constructor still calls a factory itself:
func NewOrderService() *OrderService {
return &OrderService{
payment: payment.NewDefaultPayment(),
}
}
the service is still actively looking up its own dependency.
If the factory changes its name, package, or parameters, OrderService is still affected.
We need to move that control outside the service as well.
Second: Use Dependency Injection: Do Not Fetch Dependencies Yourself
The idea behind Dependency Injection, or DI, is simple:
A component should not create or look up its own dependencies. They should be provided from the outside.
In Go, the most common approach is constructor injection:
package order
import (
"context"
"fmt"
)
type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}
type Mailer interface {
Send(ctx context.Context, to string, body string) error
}
type OrderService struct {
payment PaymentGateway
mailer Mailer
}
func NewOrderService(
payment PaymentGateway,
mailer Mailer,
) *OrderService {
return &OrderService{
payment: payment,
mailer: mailer,
}
}
func (s *OrderService) Checkout(
ctx context.Context,
orderID string,
amount int64,
email string,
) error {
if err := s.payment.Charge(ctx, amount); err != nil {
return err
}
body := fmt.Sprintf("Order %s has been paid", orderID)
return s.mailer.Send(ctx, email, body)
}
In this version, OrderService no longer:
- Imports the Stripe package
- Imports the SendGrid package
- Reads environment variables
- Calls a factory
- Decides which implementation should be used
It only declares the dependencies it needs through its constructor.
Choosing between Stripe, Paypal, SendGrid, or SES becomes the responsibility of the outermost layer, commonly called the composition root or wiring layer.
Unit Tests Become Much Simpler
Because dependencies are provided from the outside, tests can supply fake implementations:
package order_test
import (
"context"
"testing"
"myapp/order"
)
type fakePayment struct {
chargedAmount int64
}
func (f *fakePayment) Charge(
ctx context.Context,
amount int64,
) error {
f.chargedAmount = amount
return nil
}
type fakeMailer struct {
to string
body string
}
func (f *fakeMailer) Send(
ctx context.Context,
to string,
body string,
) error {
f.to = to
f.body = body
return nil
}
func TestCheckoutSendsConfirmationEmail(t *testing.T) {
payment := &fakePayment{}
mailer := &fakeMailer{}
service := order.NewOrderService(payment, mailer)
err := service.Checkout(
context.Background(),
"ORD-1",
500_000,
"[email protected]",
)
if err != nil {
t.Fatal(err)
}
if payment.chargedAmount != 500_000 {
t.Fatalf(
"charged amount = %d, want %d",
payment.chargedAmount,
500_000,
)
}
if mailer.to != "[email protected]" {
t.Fatalf(
"email recipient = %q, want %q",
mailer.to,
"[email protected]",
)
}
}
This test:
- Does not require a Stripe API key
- Does not require an Internet connection
- Does not send a real email
- Does not charge real money
- Does not modify global state
- Does not depend on the execution order of other tests
This is one of the most practical benefits of DI: business logic can be tested independently of infrastructure.
DIP and DI Solve Different Problems
These two concepts often appear together, so they are easy to confuse.
DIP answers the question:
What should the code depend on?
The answer is an abstraction, such as PaymentGateway or Mailer.
DI answers the question:
How is the dependency provided to the component?
The answer is that it is passed in from the outside, usually through a constructor.
You can have DI without DIP:
func NewOrderService(
payment *StripePayment,
mailer *SendGridMailer,
) *OrderService
The dependencies are provided from the outside, but the service still depends on concrete types.
You can also apply DIP without fully applying DI:
type OrderService struct {
payment PaymentGateway
}
func NewOrderService() *OrderService {
return &OrderService{
payment: payment.NewDefaultPayment(),
}
}
The field uses an interface, but the service still finds the implementation by itself.
When DIP and DI are used together, the business logic depends on abstractions and no longer controls how its dependencies are created.
You Do Not Need a Container to Use DI
Once constructor injection is introduced, another question appears:
If the service no longer creates its own dependencies, who does?
For a small application, the answer is often main.go:
func main() {
cfg := LoadConfig()
paymentGateway := payment.NewStripePayment(cfg.StripeKey)
mailer := sendgrid.NewMailer(cfg.SendGridKey)
orderService := order.NewOrderService(
paymentGateway,
mailer,
)
server := httpapi.NewServer(orderService)
if err := server.ListenAndServe(); err != nil {
panic(err)
}
}
This is called manual dependency injection or manual wiring.
There is no special framework involved. We simply construct objects from the outside in:
Config
├── StripePayment
├── SendGridMailer
│
└── OrderService
│
└── HTTP Server
This approach has several advantages:
- It is easy to read.
- It is easy to debug.
- There is no magic.
- The compiler checks the types.
- There is no dependency on a DI framework.
For small applications or applications with a simple dependency graph, manual wiring is often the best choice.
New Problem: When main.go Starts Looking Like a Bundle of Wires
The problem appears as the system grows:
func main() {
cfg := LoadConfig()
db := postgres.Open(cfg.PostgresDSN)
redisClient := redis.Open(cfg.RedisURL)
productRepo := product.NewPostgresRepository(db)
orderRepo := order.NewPostgresRepository(db)
addressRepo := address.NewPostgresRepository(db)
cache := cache.NewRedisCache(redisClient)
inventoryService := inventory.NewService(
productRepo,
cache,
)
shippingClient := ghn.NewClient(cfg.GHNToken)
shippingService := shipping.NewService(
shippingClient,
addressRepo,
)
paymentGateway := payment.NewStripePayment(
cfg.StripeKey,
)
mailer := sendgrid.NewMailer(
cfg.SendGridKey,
)
orderService := order.NewOrderService(
paymentGateway,
mailer,
inventoryService,
shippingService,
orderRepo,
)
server := httpapi.NewServer(
cfg,
orderService,
)
server.ListenAndServe()
}
This code is still valid from a design perspective. However, the more modules the application contains, the longer the wiring becomes.
Several common problems begin to appear:
- Constructors have many dependencies.
- The construction order becomes difficult to follow.
- Resources must be closed at the right time.
- The same dependency may accidentally be created more than once.
- A small change may require updates in several wiring locations.
main.gomay grow to hundreds of lines that only connect objects.
This is where an IoC Container such as Uber Fx can become useful.
Step 3: IoC Container
Before looking at Fx, we should separate these three concepts.
Inversion of Control
IoC is a general design principle.
Instead of allowing a component to decide how its dependencies are created, located, and managed, that control is moved outside the component.
In our example, OrderService no longer constructs StripePayment. An external layer decides which payment gateway to provide.
Dependency Injection
DI is a common way to implement IoC.
Dependencies are provided to a component through:
- Constructors
- Method parameters
- Field injection
In Go, constructor injection is usually the clearest and safest option.
IoC Container
An IoC Container, also called a DI Container, is a tool that automates wiring.
You provide the container with:
- A list of constructors
- Mappings between interfaces and implementations
- Functions that should run when the application starts
- Lifecycle hooks for startup and shutdown
The container then:
- Builds the dependency graph
- Determines the correct constructor order
- Injects dependencies into the correct places
- Reports missing dependencies
- Manages the application lifecycle
In the Go ecosystem, Uber Fx is a popular choice for this job.
This trio is a classic interview question, and candidates stumble in exactly one spot: which one is a principle and which one is a pattern. Quick distinction:
- Principle = a philosophy. It tells you what you should do, not how. Like the advice "don't put all your eggs in one basket."
- Pattern = a concrete technique that takes shape in code. Like "split your money across 3 different banks" — one way to realize that advice.
Applied to our trio:
What is it? | One-sentence answer | |
|---|---|---|
IoC | Design principle | "A class shouldn't control the creation of its own dependencies — invert that control to the outside." |
DIP | Principle (the D in SOLID) | "Depend on abstractions (interfaces), not on concrete classes." |
DI | Design pattern | "A way to realize IoC: inject dependencies from outside via constructor/method/property." |
IoC Container | A tool (framework/library) | "The machine that automates DI: creates objects and injects them according to configuration." |
Next time, if someone asks you about IoC, Dependency Injection (DI), and IoC Containers, don’t say they’re the same thing! They are closely related, but they’re not interchangeable concepts.
How Does Uber Fx Work?
Fx does not require your domain code to inherit from a special class or use decorators.
You continue writing normal Go constructors:
func NewService(
payment PaymentGateway,
mailer Mailer,
) *Service
You then register those constructors with fx.Provide.
The important point is that your domain does not need to know that Fx exists.
The Domain Does Not Depend on Fx
The order package only contains contracts and business logic:
package order
import (
"context"
"fmt"
)
type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}
type Mailer interface {
Send(ctx context.Context, to string, body string) error
}
type Service struct {
payment PaymentGateway
mailer Mailer
}
func NewService(
payment PaymentGateway,
mailer Mailer,
) *Service {
return &Service{
payment: payment,
mailer: mailer,
}
}
func (s *Service) Checkout(
ctx context.Context,
orderID string,
amount int64,
email string,
) error {
if err := s.payment.Charge(ctx, amount); err != nil {
return err
}
body := fmt.Sprintf(
"Order %s has been paid",
orderID,
)
return s.mailer.Send(ctx, email, body)
}
This package does not import:
go.uber.org/fxThis is a useful boundary to preserve. Fx belongs to the composition layer and should not spread into the domain simply because the application uses a DI Container.
Concrete Implementations
The Stripe payment gateway:
package payment
import (
"context"
"myapp/config"
)
type StripePayment struct {
apiKey string
}
func NewStripePayment(
cfg config.Config,
) *StripePayment {
return &StripePayment{
apiKey: cfg.StripeKey,
}
}
func (p *StripePayment) Charge(
ctx context.Context,
amount int64,
) error {
// Call the Stripe API.
return nil
}
The SendGrid mailer:
package sendgrid
import (
"context"
"myapp/config"
)
type Mailer struct {
apiKey string
}
func NewMailer(cfg config.Config) *Mailer {
return &Mailer{
apiKey: cfg.SendGridKey,
}
}
func (m *Mailer) Send(
ctx context.Context,
to string,
body string,
) error {
// Call the SendGrid API.
return nil
}
The configuration:
package config
import "os"
type Config struct {
StripeKey string
SendGridKey string
HTTPAddr string
}
func NewConfig() Config {
return Config{
StripeKey: os.Getenv("STRIPE_KEY"),
SendGridKey: os.Getenv("SENDGRID_KEY"),
HTTPAddr: ":8080",
}
}
Wiring Dependencies with Fx
In main.go, we register the constructors:
package main
import (
"myapp/config"
"myapp/httpapi"
"myapp/order"
"myapp/payment"
"myapp/sendgrid"
"go.uber.org/fx"
)
func main() {
app := fx.New(
fx.Provide(
config.NewConfig,
fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
),
fx.Annotate(
sendgrid.NewMailer,
fx.As(new(order.Mailer)),
),
order.NewService,
httpapi.NewServer,
),
fx.Invoke(func(*httpapi.Server) {}),
)
app.Run()
}
Fx inspects the constructors and builds the dependency graph:
config.NewConfig
│
├── payment.NewStripePayment
│ │
│ └── order.PaymentGateway
│
├── sendgrid.NewMailer
│ │
│ └── order.Mailer
│
└── httpapi.NewServer
order.PaymentGateway + order.Mailer
│
└── order.NewService
│
└── httpapi.NewServer
Fx determines which constructor must run first. We no longer need to manually arrange the construction order in main.go.
Why Do We Need fx.As?
The most important part is:
fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
)
payment.NewStripePayment returns:
*payment.StripePaymentHowever, order.NewService requires:
order.PaymentGatewayAlthough *StripePayment implements PaymentGateway, Fx does not automatically guess which interfaces the concrete type should be provided as.
fx.As explicitly tells Fx:
Register the result of this constructor as
order.PaymentGateway.
The same applies to the mailer:
fx.Annotate(
sendgrid.NewMailer,
fx.As(new(order.Mailer)),
)
Another option is to create a constructor that directly returns the interface:
func NewPaymentGateway(
cfg config.Config,
) order.PaymentGateway {
return payment.NewStripePayment(cfg)
}
Then register it normally:
fx.Provide(NewPaymentGateway)Both approaches are valid.
Returning the interface directly is often simpler. However, fx.Annotate and fx.As make the wiring layer explicitly show which implementation is bound to which abstraction, especially in larger projects with many modules.
A Quick Comparison with TypeScript and NestJS
In TypeScript, interfaces disappear when the code is compiled to JavaScript. As a result, NestJS cannot use an interface directly as a runtime token.
We usually need to define a token:
export const PAYMENT_GATEWAY = Symbol("PAYMENT_GATEWAY");Then bind an implementation to that token.
In Go, interfaces remain types that Fx can use in its dependency graph. We do not need to create custom Symbol or string tokens for ordinary cases.
However, when a constructor returns a concrete type, Fx still needs to be told which interface that type should be exposed as. That is the role of fx.As.
What Does fx.Invoke Do?
Fx only creates dependencies that are actually required by the graph.
If we only register:
fx.Provide(httpapi.NewServer)but no component requires *httpapi.Server, the constructor may never be called.
The following code creates a root dependency:
fx.Invoke(func(*httpapi.Server) {})Fx must provide *httpapi.Server to the invoked function. To do that, it constructs the entire dependency graph behind the server.
You can also use a more descriptive function:
func StartApplication(server *httpapi.Server) {
// The server has been constructed.
}
fx.Invoke(StartApplication)Managing Lifecycle with Fx
DI is not only about constructing objects.
Many dependencies also have a lifecycle:
- An HTTP server needs to start and shut down.
- A database connection needs to close.
- A Kafka consumer needs to begin consuming and stop gracefully.
- A worker needs to finish in-progress tasks before exiting.
- A metrics server needs to close its listener.
Fx provides fx.Lifecycle to manage these phases.
Here is an HTTP server example:
package httpapi
import (
"context"
"errors"
"net/http"
"myapp/config"
"myapp/order"
"go.uber.org/fx"
)
type Server struct {
server *http.Server
}
func NewServer(
lc fx.Lifecycle,
cfg config.Config,
orders *order.Service,
) *Server {
mux := http.NewServeMux()
mux.HandleFunc(
"POST /orders/checkout",
func(w http.ResponseWriter, r *http.Request) {
err := orders.Checkout(
r.Context(),
"ORD-1",
500_000,
"[email protected]",
)
if err != nil {
http.Error(
w,
err.Error(),
http.StatusBadGateway,
)
return
}
w.WriteHeader(http.StatusNoContent)
},
)
httpServer := &http.Server{
Addr: cfg.HTTPAddr,
Handler: mux,
}
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
err := httpServer.ListenAndServe()
if err != nil &&
!errors.Is(err, http.ErrServerClosed) {
// Production code should log this error
// or send it to an error channel.
}
}()
return nil
},
OnStop: func(ctx context.Context) error {
return httpServer.Shutdown(ctx)
},
})
return &Server{
server: httpServer,
}
}
When the application starts, Fx executes OnStart.
When the application receives a shutdown signal, Fx executes OnStop and provides a context.Context with a timeout. The HTTP server gets an opportunity to stop accepting new requests and wait for in-progress requests to finish.
This is where Fx provides more value than simply passing constructor parameters automatically.
Organizing Wiring into Modules
As a project grows, we should not place every fx.Provide call in one file.
Dependencies can be grouped by module:
package ordermodule
import (
"myapp/order"
"myapp/payment"
"myapp/sendgrid"
"go.uber.org/fx"
)
var Module = fx.Module(
"order",
fx.Provide(
fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
),
fx.Annotate(
sendgrid.NewMailer,
fx.As(new(order.Mailer)),
),
order.NewService,
),
)
The configuration module:
package config
import "go.uber.org/fx"
var Module = fx.Module(
"config",
fx.Provide(NewConfig),
)
The HTTP module:
package httpapi
import "go.uber.org/fx"
var Module = fx.Module(
"http",
fx.Provide(NewServer),
fx.Invoke(func(*Server) {}),
)
main.go now only acts as the composition root:
func main() {
fx.New(
config.Module,
ordermodule.Module,
httpapi.Module,
).Run()
}
The business logic still does not know that Fx exists. Only the outermost layer of the application is responsible for connecting the modules.
Replacing Stripe with Paypal
Let us return to the original request: replace Stripe with Paypal.
Suppose PaypalPayment implements the following interface:
type PaymentGateway interface {
Charge(ctx context.Context, amount int64) error
}
We only need to change the binding inside the module:
fx.Annotate(
payment.NewPaypalPayment,
fx.As(new(order.PaymentGateway)),
)
The following parts do not need to change:
order.Service- The HTTP handler
- The order unit tests
- The mailer
- The inventory service
- The shipping service
- The repository
- The
PaymentGatewaycontract
The change is limited to the composition root, which is exactly where implementation choices should be made.
This is the real benefit of loose coupling. It is not simply about writing fewer lines of code. It is about controlling the impact of a change.
Conclusion
The journey from tight coupling to loose coupling can be summarized as follows.
Initially, the service creates a concrete dependency by itself:
type OrderService struct {
payment *StripePayment
}
Next, the business logic depends on an abstraction:
type OrderService struct {
payment PaymentGateway
}
The dependency is then provided from the outside:
func NewOrderService(
payment PaymentGateway,
) *OrderService
For small applications, dependencies can be wired manually in main.go.
When the dependency graph becomes more complex, Uber Fx can automate wiring and lifecycle management:
fx.Annotate(
payment.NewStripePayment,
fx.As(new(order.PaymentGateway)),
)
The most important point is not Fx, interfaces, or constructors.
Loose coupling is about placing decisions that are likely to change in the correct location.
The checkout business logic should not know whether the system uses Stripe or Paypal. It only needs to know that a payment gateway is available and can charge a payment.
The next time someone asks:
“Could you switch SendGrid to SES?”
you should not need to open 14 different services.
The change should happen in the composition root or wiring module—the place where the application chooses its implementations in the first place
DUKE
Comments