Naming in Clean Code: Intention-Revealing Names, .NET Guidelines and Mixed Turkish-English Names
A good name answers the questions a reader would otherwise ask the code: replacing d, tmp2, k and int t in the legacy OrderService with domain names and constants makes the pricing readable without changing behavior (same totals in five scenarios). The post covers .NET naming rules, Hungarian notation, mixed Turkish-English names and the Turkish I trap that turns Id into ıd under tr-TR, with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
A controller that calls the legacy OrderService we met in the previous post contains this line:
int result = service.ProcessData(1, cart, "WELCOME10", true, false, 2);
And this is the method's signature:
public int ProcessData(int cId, Dictionary<string, int> d, string strCoupon, bool flag, bool sendMail, int t)
Try to answer without opening the file: what does true mean, what does false mean, what does 2 mean? What are the keys and values of the d dictionary? Is the returned int an order number or an error code? Then guess what the tmp, tmp2 and k variables in the method body hold.
A Name Is a Contract
The answers come from reading the method: true is gift wrap (adds 12.5 lira), false means "don't send an e-mail", 2 is express shipping. d's key is the ISBN (International Standard Book Number) and its value is the quantity. The returned int is an order number if positive and one of seven error codes if negative. tmp is the undiscounted subtotal, tmp2 the total discount, k the shipping fee. You had to read a 136-line method to learn this; the line making the call told you nothing.
That is the core of Robert C. Martin's (author of Clean Code) naming rule: a name should answer the questions a reader would otherwise ask the code. Why does this variable exist, what does it do, how is it used? If a name needs a comment, the name isn't doing its job. In the second edition the Meaningful Names chapter still opens with "Use Intention-Revealing Names"; the rule hasn't changed in seventeen years.
The pricing part of ProcessData looks like this today:
decimal tmp = 0;
foreach (var kv in d)
{
Book b = Db.Books.FirstOrDefault(x => x.Isbn == kv.Key);
if (b == null)
{
LastError = "Book not found: " + kv.Key;
return -2;
}
if (b.Stock < kv.Value)
{
LastError = "Not enough stock: " + b.Title;
return -3;
}
OrderItem oi = new OrderItem();
oi.Book = b;
oi.Qty = kv.Value;
// calculate the price
if (b.Type == "ebook")
oi.Price = b.Price * 0.8m;
else if (b.Type == "audio")
oi.Price = b.Price * 0.9m;
else
oi.Price = b.Price;
tmp += oi.Price * oi.Qty;
o.Items.Add(oi);
}
// discount
decimal tmp2 = 0;
if (strCoupon != null && strCoupon != "")
{
if (strCoupon.ToUpper() == "WELCOME10")
tmp2 = tmp * 0.1m;
else if (strCoupon.ToUpper() == "SUMMER25")
tmp2 = 25;
else
{
LastError = "Invalid coupon";
return -4;
}
}
if (c.Type.ToUpper() == "VIP")
{
tmp2 += tmp * 0.05m;
}
if (o.Items.Count > 5) tmp2 += 15;
if (DateTime.Now.DayOfWeek == DayOfWeek.Saturday || DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
tmp2 += tmp * 0.02m;
// shipping
decimal k = KargoUcretiHesapla(tmp - tmp2, t, c.Address.City.Name);
if (flag) k += 12.5m; // gift wrap
o.Discount = tmp2;
o.Shipping = k;
o.Total = Math.Round((tmp - tmp2) * 1.1m + k, 2); // 10% tax
I rewrote the same logic without touching its structure, changing only names and constants. The single difference is that an invalid coupon throws an exception instead of returning -4; we'll discuss return codes in the error handling post.
using Bookstore.Legacy;
namespace Bookstore.Ordering;
public enum ShippingMethod
{
Standard = 1,
Express = 2,
StorePickup = 3
}
public sealed class OrderPricing
{
private const string EBookFormat = "ebook";
private const string AudiobookFormat = "audio";
private const decimal EBookPriceFactor = 0.8m;
private const decimal AudiobookPriceFactor = 0.9m;
private const string WelcomeCouponCode = "WELCOME10";
private const decimal WelcomeCouponRate = 0.1m;
private const string SummerCouponCode = "SUMMER25";
private const decimal SummerCouponAmount = 25m;
private const decimal VipDiscountRate = 0.05m;
private const int BulkOrderMinLineCount = 6;
private const decimal BulkOrderDiscount = 15m;
private const decimal WeekendDiscountRate = 0.02m;
private const decimal GiftWrapFee = 12.5m;
private const decimal TaxRate = 0.10m;
private const decimal FreeShippingThreshold = 500m;
private const decimal StandardShippingFee = 39.90m;
private const decimal IstanbulStandardShippingDiscount = 10m;
private const decimal ExpressShippingFee = 89.90m;
public decimal CalculateTotal(
Customer customer,
IReadOnlyDictionary<string, int> quantitiesByIsbn,
string? couponCode,
bool includeGiftWrap,
ShippingMethod shippingMethod)
{
decimal subtotal = 0;
foreach ((string isbn, int quantity) in quantitiesByIsbn)
{
Book book = Db.Books.First(b => b.Isbn == isbn);
decimal unitPrice = book.Type switch
{
EBookFormat => book.Price * EBookPriceFactor,
AudiobookFormat => book.Price * AudiobookPriceFactor,
_ => book.Price
};
subtotal += unitPrice * quantity;
}
decimal discount = 0;
if (!string.IsNullOrEmpty(couponCode))
{
discount = couponCode.ToUpper() switch
{
WelcomeCouponCode => subtotal * WelcomeCouponRate,
SummerCouponCode => SummerCouponAmount,
_ => throw new ArgumentException($"Invalid coupon: {couponCode}", nameof(couponCode))
};
}
// ToUpper() is kept on purpose so behavior stays the same; the fix is a separate commit
if (customer.Type.ToUpper() == "VIP") discount += subtotal * VipDiscountRate;
if (quantitiesByIsbn.Count >= BulkOrderMinLineCount) discount += BulkOrderDiscount;
DayOfWeek today = DateTime.Now.DayOfWeek;
if (today is DayOfWeek.Saturday or DayOfWeek.Sunday) discount += subtotal * WeekendDiscountRate;
decimal discountedSubtotal = subtotal - discount;
decimal shippingFee = CalculateShippingFee(discountedSubtotal, shippingMethod, customer.Address.City.Name);
if (includeGiftWrap) shippingFee += GiftWrapFee;
return Math.Round(discountedSubtotal * (1 + TaxRate) + shippingFee, 2);
}
private static decimal CalculateShippingFee(decimal discountedSubtotal, ShippingMethod method, string cityName) =>
method switch
{
ShippingMethod.Standard when discountedSubtotal >= FreeShippingThreshold => 0m,
ShippingMethod.Standard when cityName == "Istanbul" => StandardShippingFee - IstanbulStandardShippingDiscount,
ShippingMethod.Standard => StandardShippingFee,
ShippingMethod.Express => ExpressShippingFee,
ShippingMethod.StorePickup => 0m,
_ => throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown shipping method")
};
}
I didn't split the method, reorder it or fix any rule. Still, it is noticeably easier to read: the line discountedSubtotal * (1 + TaxRate) says "tax is added to the discounted subtotal" by itself. In the old (tmp - tmp2) * 1.1m you had to work that out.
Renaming should not change behavior. Instead of claiming it, I measured it: a small program runs the same five scenarios through the legacy ProcessData and the new CalculateTotal:
customer 1, Standard legacy: 214.04 renamed: 214.04
customer 2, Standard legacy: 233.94 renamed: 233.94
customer 2, Express legacy: 268.94 renamed: 268.94
customer 1, Express legacy: 1156.92 renamed: 1156.92
customer 2, StorePickup legacy: 1245.09 renamed: 1245.09
Naming revealed something else, too. The legacy code says if (o.Items.Count > 5) tmp2 += 15;. Giving the constant a name forced me to write down what it counts, and it became BulkOrderMinLineCount: the discount is given for more than 5 different books, not more than 5 copies. A customer buying ten copies of the same book doesn't get it. Is that a bug or a business rule? I don't know; but now the question is visible. A good name sometimes puts the code's hidden assumptions on paper.
I also left the ToUpper() in the VIP check on purpose. A rename commit and a bug-fix commit should be separate; whoever reviews one shouldn't have to hunt for the other.
Disinformation and Noise Words
A bad name doesn't just give too little information; sometimes it gives wrong information. A few examples from the legacy file:
IsValid(Order o) looks like a question, but it sets the order's Status to 2 and writes a log entry. Someone who calls it from a view to ask "is this order valid" approves the order without knowing.
GetOrders(int cId) promises a list and returns null when there are no orders. The caller gets a NullReferenceException as soon as they write a foreach.
cnt is a counter, but of what? Reading the code, you find it counts only successful orders; the ones whose payment failed aren't counted.
flag means gift wrap. The word flag tells you only its type, not its meaning.
In the 2008 edition Martin calls these "disinformation": names that steer the reader in the wrong direction. The book's classic example is calling a variable that is actually a HashSet accountList. The form I see most often in .NET is an IEnumerable<Order> query called orderList: the name promises a materialized list, but the query runs again on every foreach.
Martin's "make meaningful distinctions" rule belongs to the same family. What is the difference between ProcessData and the commented-out ProcessDataV2? If you see three classes called Order, OrderInfo and OrderData, the names won't tell you which one to use. Words like Data, Info, Manager, Processor and Helper (Martin calls them noise words) add nothing to a name; they lengthen it so it "looks different". ProcessData actually places an order; its right name is PlaceOrder.
Searchable Names and Magic Numbers
If the tax rate had to go from 10 to 20 percent, what would you change in the legacy code? First you need to search:
$ grep -n "0\.1m\|1\.1m" OrderService.cs
86: tmp2 = tmp * 0.1m;
107: o.Total = Math.Round((tmp - tmp2) * 1.1m + k, 2); // 10% tax
185: if (coupon.ToUpper() == "WELCOME10") tmp2 = tmp * 0.1m;
191: return Math.Round((tmp - tmp2) * 1.1m + k, 2);
286: tax = (subtotal - o.Discount) * 0.1m;
378: sb.AppendLine("Tax: " + ((ara - o.Discount) * 0.1m).ToString("0.00"));
Six lines, and they are not all the same thing. The 0.1m on lines 86 and 185 is not tax, it's the 10 percent discount of the WELCOME10 coupon. Tax is written as 1.1m in two places and as 0.1m in two others. The search either finds too much or too little; you still separate the tax from the rest by reading line by line. With a single constant called TaxRate the search would return one line and the change would be one line.
The rule for magic numbers is simple: give every literal that carries meaning a name. A fixed value becomes a const, one of a set of values becomes an enum. The legacy int t parameter takes 1, 2 and 3; you only learn what they are by reading KargoUcretiHesapla. With a ShippingMethod enum the call becomes ShippingMethod.Express and the compiler makes it hard to pass an invalid value.
Not every number needs a name. The 0 in a loop, the first element of a collection, an obvious conversion like * 100 usually explain themselves. The question to ask is "if this number changes, how many places do I touch, and can I find them".
.NET's Naming Rules
Clean Code cares about what names mean; casing and prefixes are the platform's rules. In .NET there are two sources: the Framework Design Guidelines (sections reprinted on Microsoft Learn from the 2008 second edition of Krzysztof Cwalina and Brad Abrams' book of the same name) and the .NET runtime team's coding style document. The C# identifier names page on Microsoft Learn adopts the latter.
| What | Rule | Example |
|---|---|---|
| Type, method, property, event, public field | PascalCase | OrderPricing, CalculateTotal |
| Parameter, local variable | camelCase | couponCode, discountedSubtotal |
| private/internal instance field | _ + camelCase | _shippingFees |
| private/internal static field | s_ + camelCase (runtime style) | s_defaultCulture |
Constant (const) | PascalCase, even when private | TaxRate |
| Interface | I prefix | IPaymentGateway |
| Method returning an awaitable | Async suffix | PlaceOrderAsync |
| bool property | Affirmative phrase, Is/Has/Can where it helps | IsBlocked, CanShip |
| Generic type parameter | T or a descriptive name prefixed with T | TOrder |
A note on the s_ prefix: the Framework Design Guidelines say not to use a prefix on public static fields, while the runtime style recommends s_ for private static fields. They don't conflict; they cover different visibilities. With bools, the thing to watch is negative names: a name like IsNotBlocked makes the reader deal with a double negative when you write !IsNotBlocked. The Async suffix is only for methods that return an awaitable; adding Async to a synchronous method, or dropping it from an async one, misleads the caller.
A team can define most of these rules as naming rules in .editorconfig and have the IDE (Integrated Development Environment) enforce them; the details belong to the upcoming Coding Conventions series.
Why Hungarian Notation Died
The legacy strCoupon is a leftover of Hungarian notation: writing the variable's type at the start of its name (str, i, b, lst). It made sense in an era of weak type systems and editors that didn't show types. Today the IDE shows the type when you hover over it and the compiler stops you from using the wrong one. The prefix stays only as a maintenance burden: when you turn strCoupon into a Coupon value object tomorrow, the name starts lying and nobody remembers to change it. The Framework Design Guidelines forbid it outright: do not use Hungarian notation. Put meaning in the name, not the type: couponCode.
Turkish or English?
Next to English names such as ProcessData and GetQuote, the legacy file has KargoUcretiHesapla (calculate shipping fee), FaturaOlustur (create invoice), faturaNo (invoice number) and ara (subtotal). Most projects in Turkey have this mix, and it usually comes from who wrote the code rather than from a decision. The cost: to search for a concept you have to try two words (Invoice or Fatura), the same concept lives in two places under two names, and a newcomer can't tell which language is the rule.
There are two reasonable options:
- A: All code in English, with a glossary next to it.
Fatura→Invoice,İrsaliye→DeliveryNote,Kargo→Shipment,Sepet→Cart,KDV(Katma Değer Vergisi, Turkey's value added tax) →Vat(Value Added Tax). The gain: the same language as the .NET APIs (Application Programming Interfaces), no friction with a foreign developer or a library's documentation. The cost: translation debates, and concepts specific to Turkey (the e-Arşiv invoice, the tax ID number) that have no natural English equivalent. In that case it helps to pick one name (EArchiveInvoice,TaxIdNumber) and write the Turkish original in the glossary and the XML (Extensible Markup Language) doc comment. - B: Domain concepts in Turkish, technical words in English:
FaturaService,KdvOrani. The gain: you use the same word as the business, and what the product owner calls "e-Arşiv" is called that in the code too. The cost: every name mixes two languages, and decisions about ASCII (American Standard Code for Information Interchange) spelling (UcretorÜcret) never end.
I recommend A, and that's what we'll do in this series; but the real rule is "make a choice and write it in the glossary". The ubiquitous language idea from Domain-Driven Design (DDD; the approach known from Eric Evans' 2003 book) says the same: use the same concepts as the business, and give every concept a single name in the code. Which language it is comes second.
Identifiers with Turkish Characters and the Turkish I
C# allows Unicode letters in identifiers, so kargoÜcreti compiles. But the real danger of Turkish characters is not in identifiers, it's in names that turn into strings. This code, which builds a CSV (Comma-Separated Values) header from property names, runs under the Turkish culture:
using System.Globalization;
CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
// Build CSV headers from property names
string header = string.Join(";", typeof(BookRow).GetProperties().Select(p => p.Name.ToLower()));
Console.WriteLine(header);
string invariantHeader = string.Join(";", typeof(BookRow).GetProperties().Select(p => p.Name.ToLowerInvariant()));
Console.WriteLine(invariantHeader);
// An identifier with Turkish characters compiles
decimal kargoÜcreti = 39.90m;
Console.WriteLine($"{nameof(kargoÜcreti)} = {kargoÜcreti}");
public sealed record BookRow(int Id, string Isbn, string Title, decimal Price);
ıd;ısbn;title;price
id;isbn;title;price
kargoÜcreti = 39,90
Id and Isbn start with a dotless "ı" when lowercased, because in Turkish the lowercase of capital "I" is "ı". A system reading a CSV with this header can't find the isbn column. The same code with ToLowerInvariant() works correctly. The same problem appeared in the opposite direction in the previous post's "vip".ToUpper().
This is hard to catch in code review, but the .NET analyzers catch it. CA1304 and CA1311 (CA: Code Analysis) are off by default; after setting them to warning in .editorconfig, the build reported:
Program.cs(6,85): warning CA1311: Specify a culture or use an invariant version to avoid implicit dependency on current culture
Program.cs(6,78): warning CA1304: The behavior of 'string.ToLower()' could vary based on the current user's locale settings. Replace this call in '<top-level-statements-entry-point>' with a call to 'string.ToLower(CultureInfo)'.
The 39,90 on the last line belongs to the same family: the decimal was formatted with a comma according to the culture. Avoiding Turkish characters in identifiers is already a rule in most teams; the real habit is to use the invariant culture wherever a name becomes a string. For comparisons, use string.Equals(a, b, StringComparison.OrdinalIgnoreCase).
Common Pitfalls
Forgetting that renaming a public name breaks a contract. The IDE's rename updates everything the compiler can see, but it can't see the field name in JSON (JavaScript Object Notation), a property read through reflection, or a payload another service sends. Changing Qty to Quantity in a simple record:
using System.Text.Json;
using System.Text.Json.Serialization;
Console.WriteLine(JsonSerializer.Serialize(new OrderLineV1("978-0441013593", 2)));
Console.WriteLine(JsonSerializer.Serialize(new OrderLineV2("978-0441013593", 2)));
Console.WriteLine(JsonSerializer.Serialize(new OrderLineV3("978-0441013593", 2)));
// An old client still sends "Qty"
OrderLineV2? fromOldClient = JsonSerializer.Deserialize<OrderLineV2>("""{"Isbn":"978-0441013593","Qty":2}""");
Console.WriteLine(fromOldClient);
public sealed record OrderLineV1(string Isbn, int Qty);
public sealed record OrderLineV2(string Isbn, int Quantity);
public sealed record OrderLineV3(string Isbn, [property: JsonPropertyName("Qty")] int Quantity);
{"Isbn":"978-0441013593","Qty":2}
{"Isbn":"978-0441013593","Quantity":2}
{"Isbn":"978-0441013593","Qty":2}
OrderLineV2 { Isbn = 978-0441013593, Quantity = 0 }
The last line is the most dangerous: the old client keeps sending Qty, and the order line is created with quantity 0 without any error. You can clean up the internal name, but for a name exposed to the outside world, keep the old name with JsonPropertyName or version it.
Names that are too long. CalculateTotalPriceIncludingTaxAndShippingForCustomer says everything but can't be read. A name's length should fit its scope: b is enough in a three-line lambda, a class-level field needs _shippingFees. If the context already says something, don't repeat it: the method inside OrderPricing is CalculateTotal, not CalculateOrderPricingTotal.
Abbreviations. Calc, Mgr, Cust, Qty are clear in the writer's head and a guess in the reader's. The Framework Design Guidelines say not to use abbreviations or acronyms that aren't widely accepted; acronyms everyone knows such as Isbn, Vat and Http are the exception.
Putting a rename in the same commit as a behavior change. Nobody will notice a > turning into >= inside a rename diff of hundreds of lines. Rename first, tests green; then fix in a separate commit.
Not keeping the name in sync with the code. A method's job changes but its name stays. CheckStock once really only checked; today it decreases stock. A name is code too, and it gets refactored along with the code.
A Small Experiment
★☆☆ Write a line that calls the legacy CancelOrder(int id, bool refund, bool notify), then rewrite the same call with C#'s named arguments (refund: true, notify: false). Which one is easier to read? Do named arguments solve the naming problem, or just cover it up?
★★☆ List every magic number and magic string in the legacy CreateCustomer, Report and ShipOrder methods. Which ones would you name and which would you leave as they are? Is a name enough for the "Turkey" comparison in ShipOrder, or does it need something else?
★★★ Add the CA1304, CA1309 and CA1311 rules to an .editorconfig as warning and build the legacy OrderService.cs. How many warnings do you get? Fix each one with its invariant or ordinal counterpart, and use the program from the previous post to verify that the VIP (Very Important Person) discount comes back under tr-TR.
In the next post we start splitting the pricing logic whose names we just fixed: we will flatten the 136-line ProcessData with guard clauses, see that bool parameters like flag and sendMail are really two separate methods, and measure where the Cognitive Complexity of 92 goes.