Ubiquitous Language: Code That Speaks the Domain Expert's Language
Ubiquitous language is the one precise language that domain experts and developers speak inside a bounded context and that maps directly to class and method names: order.Cancel(reason) and RequestReturn instead of Status = 4. Separating ambiguous words (product, status, return versus refund), a glossary with a translation column for a team that speaks Turkish and writes English code, a reflection-based glossary check, and how HttpStatusCode followed RFC 9110's renaming.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
A note from Bookstore's weekly meeting. Grace from customer service says (in Turkish, the team's working language): "We did the iade for Ada's order, I moved the status to 4, and Linus will do the money iade once the item is back in the warehouse." The same day, a developer fixing a bug on the payment screen writes a query that assumes orders with Status == 4 have already been refunded.
How many separate concepts are in that one sentence? How many different things does "iade" (the Turkish word for both return and refund) mean? Whose "status" is it: the order's, the payment's, or the shipment's? What does "the item" correspond to in the warehouse, and what in the order? And is the developer who wrote that query right? Guess a number; at the end of the post we will translate the same sentence into code and count how many separate names come out.
Evans's Definition: Language as the Backbone of the Model
The DDD (Domain-Driven Design) Reference defines ubiquitous language as a language structured around the domain model and used by all team members within a bounded context to connect all the activities of the team with the software. Three parts of the definition matter: the language is built around the model, everyone uses it (developers and domain experts alike), and it holds within one boundary.
The problem Evans describes is familiar. Domain experts speak their jargon, developers speak in design terms, and the names inside the code are disconnected from both. Translation happens all the time, and as Evans puts it, "Translation blunts communication and makes knowledge crunching anemic." His advice is clear: use the model as the backbone of the language and use the same language in diagrams, in writing, and especially in speech. Recognize that a change in the language is a change to the model; then rename classes, methods, and modules to conform to the new model.
In practice that last sentence means: if the meeting reveals that "iade" is two separate things, the code needs two separate names as well. A team that skips this has to tell every newcomer "4 actually means the book is coming back, not that the money went out."
In Code: From Status = 4 to RequestReturn
Bookstore's old order model is the meeting sentence in code:
namespace Bookstore.Legacy;
public class Order
{
public int Id { get; set; }
// 0 = new, 1 = paid, 2 = shipped, 3 = cancelled, 4 = returned? refunded? (ask Grace)
public int Status { get; set; }
public string? Note { get; set; }
}
public class OrderManager
{
public void UpdateStatus(Order order, int status, string? note = null)
{
order.Status = status;
order.Note = note;
}
public void IadeEt(Order order) => UpdateStatus(order, 4, "returned");
public decimal GetProductPrice(int productId) => 20.00m;
}
The status is a number, its meaning is in a comment, and even the comment is unsure. The name OrderManager says nothing about what it does; IadeEt ("do the iade") drops a Turkish word into the middle of English code and still does not say which iade it means. GetProductPrice uses a word nobody at Bookstore uses: the team says "book", not "product."
Let's write the same concepts with the verbs used in the meeting. Talking to Grace and the campaign manager makes the rules clear: a cancellation only happens before the order ships and always has a reason; for a book in a delivered order, a return request is opened; and paying the money back is the payment side's job.
namespace Bookstore.Ordering;
public enum OrderStatus { Placed, Paid, Shipped, Delivered, Cancelled }
public enum CancellationReason { CustomerRequest, PaymentFailed, OutOfStock }
public sealed record ReturnRequest(string Title, string Reason);
public sealed class Order(int id)
{
private readonly List<ReturnRequest> _returnRequests = [];
public int Id { get; } = id;
public OrderStatus Status { get; private set; } = OrderStatus.Placed;
public CancellationReason? CancellationReason { get; private set; }
public string? TrackingNumber { get; private set; }
public IReadOnlyList<ReturnRequest> ReturnRequests => _returnRequests;
public void MarkPaid() => Status = OrderStatus.Paid;
public void MarkShipped(string trackingNumber)
{
if (Status is not OrderStatus.Paid)
throw new InvalidOperationException($"Order {Id} is {Status}; only a paid order can be shipped.");
TrackingNumber = trackingNumber;
Status = OrderStatus.Shipped;
}
public void MarkDelivered() => Status = OrderStatus.Delivered;
public void Cancel(CancellationReason reason)
{
if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
throw new InvalidOperationException($"Order {Id} is {Status}; it cannot be cancelled. Request a return instead.");
CancellationReason = reason;
Status = OrderStatus.Cancelled;
}
public ReturnRequest RequestReturn(string title, string reason)
{
if (Status is not OrderStatus.Delivered)
throw new InvalidOperationException($"Order {Id} is {Status}; only a delivered order can be returned.");
ReturnRequest request = new(title, reason);
_returnRequests.Add(request);
return request;
}
}
Let's run it. The last part of Program.cs calls LanguageCheck, which we will see a bit further down:
using System.Reflection;
using Bookstore.Ordering;
using Bookstore.Tools;
using Legacy = Bookstore.Legacy;
// Before: the meeting said "Ada's order was returned", the code says 4
Legacy.Order legacyOrder = new() { Id = 42 };
new Legacy.OrderManager().IadeEt(legacyOrder);
Console.WriteLine($"Legacy order {legacyOrder.Id}: Status = {legacyOrder.Status}, Note = {legacyOrder.Note}");
// After: the code says what the meeting says
Order order = new(43);
try
{
order.RequestReturn("Dune", "Damaged cover");
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
order.MarkPaid();
order.MarkShipped("TRK-1001");
order.MarkDelivered();
ReturnRequest request = order.RequestReturn("Dune", "Damaged cover");
Console.WriteLine($"Order {order.Id}: {order.Status}, return requested for {request.Title} ({request.Reason})");
try
{
order.Cancel(CancellationReason.CustomerRequest);
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
Console.WriteLine();
foreach (string ns in new[] { "Bookstore.Legacy", "Bookstore.Ordering" })
{
List<string> findings = [.. LanguageCheck.Scan(Assembly.GetExecutingAssembly(), ns)];
Console.WriteLine($"{ns}: {findings.Count} finding(s)");
findings.ForEach(f => Console.WriteLine($" {f}"));
}
Legacy order 42: Status = 4, Note = returned
Order 43 is Placed; only a delivered order can be returned.
Order 43: Delivered, return requested for Dune (Damaged cover)
Order 43 is Delivered; it cannot be cancelled. Request a return instead.
In the old model, what 4 means is still unclear, and an order that has not even been delivered can happily become 4. That is also why the developer's query in the guessing question is wrong: Grace's 4 means "the book was asked back", not "the money was paid back"; Linus has not done the refund yet. In the new model every transition is a method: MarkPaid, MarkShipped(trackingNumber), MarkDelivered, Cancel(reason), RequestReturn(title, reason). The Status setter is private; the only way to change the status is a verb from the business language. Requesting a return on an undelivered order and cancelling a delivered one both fail with the business's own sentences.
The enum has not disappeared; OrderStatus is still there, but now it is a result you query, not a value you assign. What changed is the words used to change the status. This is also a step beyond the SetCancelledStatus() naming in eShop that we mentioned in the previous post: the rule is protected, and the name is in the business language too.
Separating Ambiguous Words
The most valuable output of a ubiquitous language is often not new words but old words taken apart. At Bookstore these cause the most trouble:
- "Product": in Catalog, the book record on sale (
Book); in the warehouse, the count of physical copies (StockItem); in an order, the line that was bought (OrderItem). The team stops saying "product" and says which one it means. - "Status": the order's status (
OrderStatus), the shipment's status (ShipmentStatusin Shipping), the payment's status. Each has different values and transitions. - "Iade": sending the book back (
Return,ReturnRequestin Ordering) and paying the money back (Refund, in Payment). One can happen without the other: for a damaged book, the customer may get a partial refund without sending the book back. - "Customer": the buyer in Ordering (
Customer), a user account on the authentication side, the recipient of an e-mail in Notification. The same person, three different models.
That last item is an important hint: some words cannot be separated inside one model, because in different places they really are different things. Trying to merge what "customer" means in Ordering and in Notification into one class leads to the bounded context, the topic of the fourth post. Ubiquitous language does not mean "one language everywhere"; it means "one precise language inside a boundary."
A Team That Speaks Turkish and Writes Code in English
In most Turkish teams the domain language is Turkish and the code is in English. In meetings people say "kısmi iptal", "iade talebi", "kampanya"; in code they write CancelItem, ReturnRequest, Promotion. This does not conflict with DDD, but it requires making the translation once, as a shared decision. The solution is to add a translation column to the glossary:
| Domain language (TR) | Code name | Context | Meaning |
|---|---|---|---|
| sipariş | Order | Ordering | An order placed by the customer, awaiting or after payment |
| sipariş satırı | OrderItem | Ordering | One book, a quantity, and the unit price at that moment |
| iptal | Cancel(reason) | Ordering | Taking an order back before it ships |
| iade (book) | ReturnRequest | Ordering | A request to send back a delivered book |
| iade (money) | Refund | Payment | Paying the money back to the customer |
| kampanya | Promotion | Ordering | A price discount rule such as school season or year end |
| kargo | Shipment, Parcel, carrier | Shipping | The shipping process, the physical box, and the carrier company: three separate concepts |
Two rows in the table show why translation should not be left to individuals. Someone who looks up "kampanya" in a dictionary writes Campaign; but in English a campaign is usually a marketing campaign (an e-mail blast, an ad), while a price discount rule is a promotion. And in Turkish "kargo" can mean the shipment, the box, and the carrier company; in code these must be three different names.
A few rules help. Give every Turkish term exactly one English equivalent, chosen once and together with the domain expert. Do not mix Turkish and English in identifiers: names like IadeEt or SiparisIptal damage both the language and searchability. Pick the business verb instead of a word-for-word translation: "update the order status" is not UpdateOrderStatus, it is whatever actually happened: Cancel, MarkShipped. Code names can also be used freely in meetings; "has the ReturnRequest been opened?" is a perfectly natural sentence in a Turkish-speaking team.
Keeping the Language in the Code: A Glossary Check
A glossary that lives only in a document gets forgotten at the first urgent task. Some of its rules can be made automatically checkable. The small tool below scans the public type and member names in a namespace with reflection and finds the words the glossary forbids:
using System.Reflection;
namespace Bookstore.Tools;
public static class LanguageCheck
{
// Glossary rules: word that must not appear in code -> what the team says instead
private static readonly Dictionary<string, string> Forbidden = new()
{
["Product"] = "say Book, StockItem or OrderItem",
["Manager"] = "name the responsibility",
["Iade"] = "say Return (the book) or Refund (the money)",
};
public static IEnumerable<string> Scan(Assembly assembly, string ns)
{
foreach (Type type in assembly.GetTypes().Where(t => t.Namespace == ns))
{
IEnumerable<string> names = type.GetMembers(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(m => m.Name)
.Prepend(type.Name);
foreach (string name in names.Distinct())
foreach ((string word, string advice) in Forbidden)
if (name.Contains(word, StringComparison.OrdinalIgnoreCase))
yield return $"{type.Name}.{name}: '{word}' -> {advice}";
PropertyInfo? status = type.GetProperty("Status");
if (status?.SetMethod?.IsPublic == true)
yield return $"{type.Name}.Status has a public setter -> use domain methods (Cancel, MarkShipped...)";
}
}
}
The second part of the same run of the program above:
Bookstore.Legacy: 4 finding(s)
Order.Status has a public setter -> use domain methods (Cancel, MarkShipped...)
OrderManager.OrderManager: 'Manager' -> name the responsibility
OrderManager.IadeEt: 'Iade' -> say Return (the book) or Refund (the money)
OrderManager.GetProductPrice: 'Product' -> say Book, StockItem or OrderItem
Bookstore.Ordering: 0 finding(s)
Put this check in a unit test, and a pull request that adds a class named ProductManager to Bookstore.Ordering turns red in CI (Continuous Integration), so the discussion starts before code review. Of course the tool does not understand the language; it only reminds the team of decisions it already made. The answer to "why don't we use this word?" sits right next to it as a comment. Evans's Continuous Integration pattern in the Reference says the same thing: keep model concepts and terms consistent within a bounded context through frequent merging and automated tests.
.NET's Own Language: HttpStatusCode and RFC 9110
A good example of ubiquitous language sits inside the BCL (Base Class Library). The documentation of the System.Net.HttpStatusCode enum says its values are as defined in section 15 of RFC (Request for Comments) 9110, HTTP (Hypertext Transfer Protocol) Semantics. Here the domain is HTTP itself and the domain experts are the IETF (Internet Engineering Task Force) working group that wrote the standard; .NET's names follow their language: NotFound, Conflict, TooManyRequests.
So what happens when the domain language changes? In 2022, RFC 9110 renamed status code 422 from "Unprocessable Entity" to "Unprocessable Content." .NET added UnprocessableContent to the enum but did not remove the old UnprocessableEntity; deleting a name from a public API (Application Programming Interface) breaks everyone who uses it. The result:
using System.Net;
HttpStatusCode code = HttpStatusCode.UnprocessableContent;
Console.WriteLine($"{(int)code} {code}");
Console.WriteLine(HttpStatusCode.UnprocessableContent == HttpStatusCode.UnprocessableEntity);
422 UnprocessableEntity
True
A value assigned under the new name prints under the old one. Because both names map to the same number, ToString picks one of them, and the old word lives on in logs and error messages. The lesson cuts both ways. First, a change in the language really is a change to the model, and it has a cost. Second, inside your own bounded context that cost is low: where the code is not exposed to the outside, renaming is as cheap as an IDE (Integrated Development Environment) refactoring. Rename as soon as the language becomes clear; do not keep synonyms living side by side.
Where Does the Glossary Live?
In most teams the glossary starts on a wiki page and dies there. The place for a ubiquitous language is next to the code: a short file in each bounded context's folder, versioned with the code and reviewed with it in pull requests. When a new term first appears as a class name, the same pull request adds a line to the glossary. The domain expert may not read the code, but can read this file and object to it.
The glossary is owned by the team and the domain expert together. Four things per line are enough: the Turkish term, the code name, the context it is valid in, and a one-sentence meaning. A "do not use" list is valuable too: "product", "status", "iade" on its own, and suffixes like "Manager", "Data", and "Info" that say nothing. The Bounded Context Canvas we will see in the fourth post also has a dedicated field for the ubiquitous language; there the glossary becomes part of the context's identity.
Common Pitfalls
The language living only in the wiki. If the glossary does not change together with the code, within a few months the two speak different languages, and a newcomer cannot tell which one to believe.
Leaving translation to each developer. The same "iade" becomes Return in one class, Refund in another, and Reimbursement in a third. Translation is a one-time team decision, and it goes into the glossary.
Carrying technical language into the meeting. A domain expert cannot understand, or object to, "OrderManager sets the status to 4." Code names should come from the business language, not the other way around.
Forcing words to mean the same thing everywhere. "Customer" meaning different things in Ordering and Notification is not a bug; it is a sign of two separate models. One giant Customer class hides that difference; boundaries are the topic of the fourth post.
Leaving the enum directly assignable. As long as order.Status = OrderStatus.Cancelled compiles, the rules in the business-language Cancel method can be bypassed. Let only meaningful methods change the status.
A Small Experiment
★☆☆ Go back to the sentence in the guessing question and rewrite it with the code names from this post. How many separate names did you get? (Our count: Order, ReturnRequest, Refund, OrderStatus, StockItem; "moved the status to 4" maps to no name at all, because it is now a method call.)
★★☆ Move LanguageCheck into an xUnit test and write an assertion that expects zero findings for Bookstore.Ordering. Then make it read the glossary rules from a glossary file in the repository instead of the dictionary in the code.
★★★ Pick the five most used domain words in your own project. For each one, ask three people on the team separately "what exactly does this word mean?" and compare the answers. For the words that come out differently, write the glossary line and estimate the cost of renaming their counterparts in the code.
In this post we lined the language up with the code. But while writing the glossary you noticed something: on some topics you talk more, argue more, and change rules more often; on others a single line is enough. In the next post we give that difference a name with subdomains: using the core, supporting, and generic split to decide where Bookstore should invest.