Overview

6 Money converter: CLI around an HTTP call

This chapter walks through building a pocket-sized Go CLI that converts money amounts between ISO-4217 currencies by combining clean domain modeling, careful numeric handling, and an HTTP call. It motivates a command-line approach for speed and composability, then sets practical requirements and limitations: parse a single amount and two currencies, round safely to each currency’s precision, and avoid non-decimal systems and malformed inputs. From the outset, it stresses that real-world money demands exactness and guards against floating-point pitfalls by using fixed-precision representations and explicit validation, making the project educational rather than production-grade.

The implementation centers on a small “money” package with immutable value objects: Currency (code and precision), Decimal (fixed-precision subunits), and Amount (quantity plus currency). Parsing functions turn strings into valid business types, expose meaningful domain errors, and simplify trailing zeros; tests use table-driven cases, validation functions, and helpers to keep design testable. Conversion avoids floats by multiplying Decimals via an ExchangeRate, adjusts precision with pow10, and validates results against bounded ranges. For usability, types implement Stringer; for the CLI, the main package reads flags and arguments with the flag package, parses inputs into business types, and prints readable results—while deliberately keeping parsing/validation inside the library.

To fetch real exchange rates, the chapter adds a separate ecbank client that calls the European Central Bank’s XML endpoint with net/http, checks status codes, decodes with encoding/xml, and computes cross-currency rates via EUR. Dependency injection decouples the money package from the HTTP client: Convert accepts an interface for fetching rates, enabling stubs in tests and mock HTTP servers via httptest. The chapter closes with pragmatic improvements—caching rates, adding timeouts and a dedicated http.Client, inspecting errors with errors.As, and noting alternative project layouts—reinforcing robust error handling, testability, and maintainable design around a simple, useful CLI.

Summary

  • The flag package exposes functions that allow us to retrieve both arguments and optional parameters from the command line. We can even set default values to our flags. Remember to call flag.Parse() before checking the values of the flags!
  • When implementing a functionality, it is good to declare types that mirror the core entities that we will have to handle. In our case, we created a Currency, a Decimal, an Amount, and we defined what Convert should do before writing a single mathematical operation or calling a single function. We also knew from the start how to organise our code, which types and functions should be exposed.
  • The fmt.Stringer interface is a simple interface that will make printing complex structures nicely.
  • Floating point numbers are inaccurate, at best. There should always be room for a margin when comparing two floating point numbers. Operations on floats will sometimes seem nonsensical, because of the precision limitation. Knowing their precision and the precision of the computation they’re used for will help make a choice of float32, float64, big.Float - or to go for something else.
  • A package is most often built starting with its API, and finishing with its unexposed functions.
  • Go’s net/http package offers functions to perform HTTP calls, such as GET or POST requests, over the network. It offers a default client, which can be used for prototyping, but shouldn’t be kept in production-level code, for security reasons.
  • A HTTP call will return a response that contains a status code. Checking this status code is mandatory - the code informs how meaningful the body of the response is. Status codes are divided into 5 classes, but the most important are 200 (and 2xx), which means everything went fine, 400 (and 4xx) which means the request might be incorrect - some fields could be missing, some authentication could be wrong, … -, and 500 (and 5xx), which mean the server faced an issue and couldn’t process the request.
  • Testing HTTP calls should be agnostic from external behaviour. Go provides a httptest package to mock HTTP calls by providing an infrastructure to set up an HTTP server for tests. a useful NewServer function.
  • A clean code separates the responsibilities of each package. Retrieving data isn’t the same task as computing data, and should be handled in a separate piece of code. Go offers extremely simple interfaces that allow for dependency injection. Dependency injection makes code simple, and tests even simpler.
  • Stubs are a very nice way of implementing interfaces. Simply declare a struct where you need to implement an interface - usually in a _test.go file - and have it implement the interface. A stub is very useful when trying to improve test coverage, but it can only be used for unit tests, as they don’t check the whole logic of the call.
  • Exposed functions, in a package, should return sentinel errors. This makes using a package clean and simple. Within the implementation of the package, the decision of creating the sentinel errors in exposed functions or in unexposed functions is left to the developer.
  • Using errors.Is how we test if an error is a sentinel. Using errors.As is how we access an error’s fields and methods.
  • Go’s encoding/xml package provides a function and a method to decode an XML message. In order to be able to decode some bytes into a structured variable, Go’s syntax requires that the fields we want to decode be described with the XML path where their value can be read. It is even possible to skip layers by using the > character in that path.
  • The toolchain offers the go build -o path/to/exec . command, which generates an executable file at the specified location.

FAQ

Why not use float32/float64 to represent money?IEEE-754 floating-point types lose decimal precision and can break invariants when adding/subtracting. Examples: 123456789 cannot be represented exactly in float32; 0.0123456789 rounds unexpectedly; subtracting 1 from 100,000,000 in float32 can result in no change. For money, use fixed precision: a Decimal type that stores an integer subunit count plus a precision avoids these errors.
How is Decimal implemented and parsed from a string?Decimal stores value as subunits (int64) and precision (byte). ParseDecimal splits the input on a dot with strings.Cut, concatenates integer and fractional parts, parses to int64, bounds-checks (max 10^12), sets precision to len(fracPart), and simplifies trailing zeros (e.g., 1.50 → 1.5 subunits/precision representation) to keep a canonical form. It returns domain errors like ErrInvalidDecimal and ErrTooLarge.
How do Currency and Amount enforce valid precision?Currency holds a 3-letter ISO-4217 code plus its precision (e.g., EUR=2, BHD=3, IRR=0). ParseCurrency validates the code length and returns the appropriate precision (default 2). NewAmount(quantity, currency) returns ErrTooPrecise if quantity.precision exceeds currency.precision; if it’s smaller, it scales subunits up and sets precision to the currency’s so the Amount always matches the currency’s smallest unit.
What does Convert do end-to-end?Convert(amount, to, rates) fetches the ExchangeRate (via a dependency it receives), applies it to the input Amount using applyExchangeRate, adjusts precision to the target Currency, validates the result (range and precision), and returns the converted Amount or an error. The rate-fetching is injected, so Convert doesn’t know where rates come from.
How are exchange rates applied without losing precision?ExchangeRate is a Decimal. multiply(Decimal, ExchangeRate) multiplies subunits and adds precisions, then simplify() removes trailing zeros. applyExchangeRate() adjusts the resulting Decimal’s precision to target currency’s (multiply/divide by powers of 10 using a pow10 helper), then returns an Amount with the target Currency.
How do I build the CLI (flags and argument) and wire parsing?- Flags: use flag.String("from", "", "source currency, required") and flag.String("to", "EUR", "target currency"); call flag.Parse().
- Argument: the amount is flag.Arg(0); if empty, show usage and exit non-zero.
- Parse: money.ParseCurrency(*from), money.ParseCurrency(*to), money.ParseDecimal(value), money.NewAmount(quantity, fromCurrency). Then call money.Convert(amount, toCurrency, ratesProvider).
How do types print nicely in the CLI output?Implement fmt.Stringer:
- Currency.String() returns the ISO code.
- Decimal.String() builds a format with zero-padding based on precision (e.g., %d.%02d for precision 2) using pow10 to split integer and fractional parts.
- Amount.String() joins quantity.String() and currency code (e.g., “22.368 KWD”).
How are real rates fetched from the ECB and how is the XML parsed?ecbank.Client.FetchExchangeRate(source, target) performs an HTTP GET to the ECB daily XML endpoint, checks the HTTP status, and decodes the XML with encoding/xml.Decoder into an envelope type that maps nodes. It builds a map of code→rate, inserts EUR=1, and computes target/source via map lookup. Always defer resp.Body.Close() and return package-specific sentinel errors for bad status, unexpected format, or missing rates.
How do I test HTTP code and timeouts without calling the real API?- Use httptest.NewServer to serve canned XML (or delays) and point the client at that URL (via an overridable field or a custom http.Client with Proxy).
- Set http.Client{Timeout: …} to enforce timeouts; detect them with errors.As(err, *url.Error) and urlErr.Timeout().
- Inject dependencies: Convert takes an interface (or function) to fetch rates; in tests, stub it with a minimal struct that returns fixed rates or errors.
What are key limitations, validations, and pitfalls to watch?- Input number: digits with at most one dot; no grouping separators; only decimal currencies supported.
- Bounds: reject values over 10^12; validate Amount precision versus Currency precision.
- Don’t rely on String() to obtain ISO codes; expose/get code via a dedicated method.
- Close resp.Body, handle non-2xx status codes, and set reasonable HTTP timeouts.
- Avoid import cycles; keep internal fields unexported and construct via builders; use sentinel/domain errors for ergonomic error handling.

pro $24.99 per month

  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose one free eBook per month to keep
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime

lite $19.99 per month

  • access to all Manning books, including MEAPs!

team

5, 10 or 20 seats+ for your team - learn more


choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Learn Go with Pocket-Sized Projects ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Learn Go with Pocket-Sized Projects ebook for free
choose your plan

team

monthly
annual
$49.99
$499.99
only $41.67 per month
  • five seats for your team
  • access to all Manning books, MEAPs, liveVideos, liveProjects, and audiobooks!
  • choose another free product every time you renew
  • choose twelve free products per year
  • exclusive 50% discount on all purchases
  • renews monthly, pause or cancel renewal anytime
  • renews annually, pause or cancel renewal anytime
  • Learn Go with Pocket-Sized Projects ebook for free