Most web applications eventually need multilingual support. The interesting design question is how should I structure my domain objects in DDD when it is related to languages.
How does my framework know the correct language?
Preferred language in a browser
Your browser is setup on a default language. If a browser does a HTTP request it does provide a header with the preferred language. A webform also has a language and it could differ from the browser language or the language of he visited page if you would add the lang attribute. The headers we need are Accept-Language for getting content in a specific language and Content-Language to submit a form submit/API call submits data in that language.
So it is possible to do an API call to create an English object and ask for the Dutch one:
POST /customer HTTP/1.1
Content-Language: en
Accept-Language: nl
Content-Type: application/json
{"name":"English name"}
An API could respond by ignoring the Accept-Language header, but it could also respond with 406 Not Acceptable depending what is desired here. But would the customer be created with a 406 response? To make it worse is that frameworks tend to use only one of the 2 headers to determine the language of the client (in most cases Accept-Language).
The language format is not a hard requirement, but most servers communicate with the BCP47 spec on how to describe a language. There is also no defined list of accepted languages: a client could provide any value as a language. In general the list of possible languages is retrieved from the IANA Subtag Language Registry and written. For example 'en-US' means English with US region to distinguish between American and British English, but technically I can also pick 'en-NL' to define English from the Netherlands. There could be more settings mentioned.
You could also provide all of them, for example: en-Latn-UK-oxendict-u-x-company to determine British English with latin character set with spelling from oxford dictionary in unicode with custom field 'x-company'. In general most servers will accept any string with dashes and strings.
Preferred language in console
How about running console commands and using the current user's language? Unlike the browser one this one depends on the framework. In Linux you can read environment variables like LANG, LC_ALL and LC_MESSAGES.
Windows allows you to get a value from the registry in a windows command line:
reg query "HKCU\Control Panel\International" /v LocaleName
The easiest solution is install the intl php extension and ask PHP what it is with Locale::getDefault(). It is still recommended to check the LANG environment variable as well so you can overwrite it:
$locale = getenv('LC_ALL')
?: getenv('LC_MESSAGES')
?: getenv('LANG')
?: Locale::getDefault()Mapping localizations to domain objects
So how do I map a domain object in multiple languages? The interesting part is that if you are really doing DDD your object is the one making the decision how to handle it.
Solution 1: one domain object just contains one language
So the easiest solution is to make the domain object just a single language. If you create the domain object you have to provide the language in the constructor and the value can never change.
use PrinsFrank\Standards\Language\LanguageAlpha2;
final class Page implements EntityInterface
{
private PageIdentifier $id;
public function __construct(LanguageAlpha2 $language, public NonEmptyString $description)
{
$this->id = new PageIdentifier($language, Ulid::createRandom());
}
public function getLanguage(): LanguageAlpha2
{
return $this->id->getLanguage();
}
}
So any property, getter or setter you add just applies to this page and it has a language that you can only set on creation. In fact in my example I use a snowflake identifier(see my awesome article about different types of id's) to store the language even in the identifier. If a page is in two languages you have two domain object instances.
The downside of one locale per domain object is that the pages are all separate, so it is harder to find the same page in a different language. And how would the program respond if you switch to a different language and the current page is not available in this language?
Choose this when:
- translations evolve independently
- translations have different approval workflows
- some languages may never exist
- each translation has its own lifecycle
Solution 2: one aggregate contains every language
The most straight forward solution for this would be to let a domain object contain ALL translations and handle everything encapsulated in the domain object.
This way the domain object also determines how to handle cases where the object is not available in this language. Internally your domain class is using a localized object for every language. Often this class has the same name as the domain object with a suffix like 'I18n' or 'Locale'.
If there is a fallback language option, then this should be initialized in the constructor. Of course if the fallback language option is missing a field, then you might throw an exception or return null depending on the domain logic you use (or prevent this from happening by making the object in the constructor).
use PrinsFrank\Standards\Language\LanguageAlpha2;
final class Page implements EntityInterface
{
private PageIdentifier $id;
private PageLocaleMap $locales;
public function __construct(
private LanguageAlpha2 $fallbackLocale,
PageLocale $fallbackPage
) {
$this->locales = new PageLocaleMap([
$fallbackLocale->value => $fallbackPage
]);
$this->id = PageIdentifier::createRandom();
}
public function getId(): PageIdentifier
{
return $this->id;
}
private function findFallbackPage(): PageLocale
{
return $this->locales[$this->fallbackLocale->value];
}
private function findOrCreateLocale(LanguageAlpha2 $locale): PageLocale
{
if (!isset($this->locales[$locale->value])) {
$this->locales[$locale->value] = new PageLocale();
}
return $this->locales[$locale->value];
}
public function setDescription(?LanguageAlpha2 $locale = null, NonEmptyString $description): Page
{
$this->findOrCreateLocale(
$locale ?? $this->fallbackLocale
)->description = $description;
return $this;
}
public function getDescription(?LanguageAlpha2 $locale = null): NonEmptyString
{
return $this->findOrCreateLocale($locale ?? $this->fallbackLocale)->description
?? $this->findFallbackPage()->description
?? NonEmptyString::fromNative(' ');
}
}
Of course the more properties this domain object has the harder it is to read this class. Also having lots of setters and getters is often not the DDD-way of setting up your object(but it happens a lot because of how frameworks and ORM's handle entities).
You could also make the caller responsible for providing all fields of a language in a single setter. Make sure PageLocale is readonly so no outside sources could change this object (or return a clone of the object):
use PrinsFrank\Standards\Language\LanguageAlpha2;
final class Page implements EntityInterface
{
private PageIdentifier $id;
private PageLocaleMap $locales;
public function __construct(
private LanguageAlpha2 $fallbackLocale,
PageLocale $fallbackPage
) {
$this->locales = new PageLocaleMap([
$fallbackLocale->value => $fallbackPage
]);
$this->id = PageIdentifier::createRandom();
}
public function getId(): PageIdentifier
{
return $this->id;
}
public function getLocaleFields(?LanguageAlpha2 $locale = null): PageLocale
{
$locale ??= $this->fallbackLocale;
return clone $this->locales[$locale->value];
}
public function setLocaleFields(?LanguageAlpha2 $locale = null, PageLocale $pageContents): Page
{
$locale ??= $this->fallbackLocale;
$this->locales[$locale->value] = clone $pageContents;
return $this;
}
}
This better preserves the aggregate's invariants because translations are updated as a single object. The biggest difference is whether you want a partial translation or not. With this setting the PageLocale class determines whether all fields are required or not.
Choose this when:
- translations are inseparable
- all languages represent the same business concept
- updates should remain consistent
- fallback behavior belongs to the domain
DDD and multi-language support in Apie
In my library Apie the above examples will work: make setters with more than one argument. Only the last setter argument is used for setting a value; the rest of the arguments is considered to add context: for example being logged in or which language you picked.
You could also make a call using the Content-Language or Accept-Language header either by renaming $locale to $dataLocale or $acceptLocale or by adding a #[Context] attribute to pick the correct one. That is not something a framework provides.
Conclusion
So the conclusion is that if you are multi-language and want to work with domain objects your domain object determines how the object is multi-language. Do you have an object per translation or do you make a big root aggregate with all the translations stored encapsulated? And do you need to provide all fields for every translation or is it ok to display some fields in the default fallback translation.
Multi-language support is often treated as a persistence or UI concern. In DDD, it's a modeling decision. Before choosing database tables or localization libraries, decide what your domain considers to be the identity of a translated object. Everything else follows from that.
Comments
Post a Comment