Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 3 · 3. Control Flow, Methods, Delegates, and Functional Building Blocks · Lesson 8 of 24

    Methods, Parameters, Overloads, and Clear API Contracts

    Learning outcomes

    Design a method call whose required inputs, defaults, errors, result, and mutation rules are clear. Trace overload selection and ordinary/ref/in/out calls. Refactor six related primitive parameters without hiding validation or changing behavior.

    Read the contract before the implementation

    A useful contract answers: What must the caller supply? What does omission mean? What happens for invalid input? What does success return? Does the operation change caller-owned state or keep anything for later? A signature starts those answers; validation, documentation, and tests finish them.

    The three programs below are independent. With the .NET 10 SDK, create a console project targeting net10.0, replace Program.cs with one complete listing, and run it. Use a separate project for each listing. They require no packages, files, network, timers, or actual payments. The console output is the test trace, not an external side effect of the calculation helpers.

    1. Retain the quote example, make its boundaries explicit

    The IN 18%, GB 20%, and other 0% table is a synthetic teaching policy. It is not production tax logic, GST/VAT guidance, or legal advice. The labels are lookup keys for this exercise; an unknown key yielding zero does not establish any real tax exemption.

    • The request and CountryCode must be non-null; the code must not be empty or whitespace. Subtotal must be nonnegative.
    • Case is normalized, but spaces are not trimmed. Thus “gb” selects 20%, while “ IN ” deliberately falls through to zero. This preserves the original example rather than silently changing its accepted inputs.
    • Tax is rounded to two decimal places using midpoint-to-even. Total is the original subtotal plus the rounded tax; the total itself is not separately rounded.
    • Normal completion returns a new Quote with named Tax and Total values, never null. The helper neither modifies nor stores the supplied request. It makes no purchase and manages no disposable resource.
    • A null request or country causes ArgumentNullException; blank country causes ArgumentException; negative subtotal causes ArgumentOutOfRangeException. Arithmetic beyond decimal’s range can cause OverflowException. No partial quote is returned on those paths.
    C#
    using System;
    using System.Globalization;
    
    internal static class Program
    {
        private static void Main()
        {
            CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
            Show("IN", new QuoteRequest(100m, "IN"));
            Show("lowercase", new QuoteRequest(100m, "gb"));
            Show("fallback", new QuoteRequest(100m, "ZZ"));
            Show("not-trimmed", new QuoteRequest(100m, " IN "));
            Show("zero", new QuoteRequest(0m, "IN"));
            Show("midpoint", new QuoteRequest(0.025m, "GB"));
            Expect<ArgumentNullException>("null-request", () => CalculateQuote(null!));
            Expect<ArgumentNullException>("null-country", () => CalculateQuote(new(1m, null!)));
            Expect<ArgumentException>("blank-country", () => CalculateQuote(new(1m, " ")));
            Expect<ArgumentOutOfRangeException>("negative", () => CalculateQuote(new(-1m, "IN")));
            Expect<OverflowException>("overflow", () => CalculateQuote(new(decimal.MaxValue, "GB")));
        }
    
        // SYNTHETIC teaching policy, not production tax logic or GST/VAT guidance.
        private static Quote CalculateQuote(QuoteRequest request)
        {
            ArgumentNullException.ThrowIfNull(request);
            ArgumentException.ThrowIfNullOrWhiteSpace(request.CountryCode);
            if (request.Subtotal < 0) throw new ArgumentOutOfRangeException(nameof(request));
            var rate = request.CountryCode.ToUpperInvariant() switch
            {
                "IN" => 0.18m,
                "GB" => 0.20m,
                _ => 0m
            };
            var tax = decimal.Round(request.Subtotal * rate, 2);
            return new Quote(tax, request.Subtotal + tax);
        }
    
        private static void Show(string label, QuoteRequest request)
        {
            var quote = CalculateQuote(request);
            Console.WriteLine($"{label}: tax={quote.Tax:F2}; total={quote.Total:F3}");
        }
    
        private static void Expect<T>(string label, Action action) where T : Exception
        {
            try { action(); }
            catch (Exception ex) when (ex.GetType() == typeof(T))
            {
                Console.WriteLine($"{label}: {ex.GetType().Name}");
                return;
            }
            throw new Exception($"Missing expected {typeof(T).Name}: {label}");
        }
    }
    
    public sealed record QuoteRequest(decimal Subtotal, string CountryCode);
    public sealed record Quote(decimal Tax, decimal Total);

    Expected stdout:

    Text
    IN: tax=18.00; total=118.000
    lowercase: tax=20.00; total=120.000
    fallback: tax=0.00; total=100.000
    not-trimmed: tax=0.00; total=100.000
    zero: tax=0.00; total=0.000
    midpoint: tax=0.00; total=0.025
    null-request: ArgumentNullException
    null-country: ArgumentNullException
    blank-country: ArgumentException
    negative: ArgumentOutOfRangeException
    overflow: OverflowException

    Read the midpoint case carefully: 0.025 × 0.20 = 0.005, so tax rounds to 0.00 and total remains 0.025. Showing three decimal places for Total keeps that visible. The overflow test reaches the final addition; it does not turn an out-of-range value into a successful quote. The exception helper checks exact exception types and fails the program if its expected exception never arrives.

    The null-forgiving marks in negative tests deliberately pass null to exercise guards. They do not validate those inputs.

    2. Calls, overloads, and parameter modes

    Named arguments identify parameters at the call site. Optional parameters allow omission and provide declared defaults; a nullable type alone does not make a parameter optional. Overloads share a name but expose different parameter lists. These statically bound calls choose a matching overload at compilation, not by the return value the caller hopes to receive.

    C#
    using System;
    
    internal static class Program
    {
        private static void Main()
        {
            Console.WriteLine(Describe("parcel"));
            Console.WriteLine(Describe(item: "parcel", copies: 3));
            Console.WriteLine(Describe("parcel", 2, "rush"));
            Console.WriteLine(Route("parcel"));
            Console.WriteLine(Route("parcel", "manual"));
            Console.WriteLine(Pick(7));
            Console.WriteLine(Pick(7L));
    
            int number = 4;
            ReplaceValue(number);
            Console.WriteLine($"ordinary: {number}");
            ReplaceRef(ref number);
            Console.WriteLine($"ref: {number}");
            Console.WriteLine($"in: {Read(in number)}; caller={number}");
            AssignOut(out int assigned);
            Console.WriteLine($"out: {assigned}");
    
            var box = new Box { Value = 2 };
            EditObject(box);
            Console.WriteLine($"ordinary object: {box.Value}");
            EditThroughIn(in box);
            Console.WriteLine($"in object: {box.Value}");
            ReplaceBox(ref box);
            Console.WriteLine($"ref object: {box.Value}");
        }
    
        private static string Describe(string item, int copies = 1, string label = "standard")
            => $"{item}: {copies}, {label}";
        private static string Route(string item) => Route(item, "auto");
        private static string Route(string item, string mode) => $"{item}: {mode}";
        private static string Pick(int number) => "int overload";
        private static string Pick(long number) => "long overload";
        private static void ReplaceValue(int value) { value = 9; }
        private static void ReplaceRef(ref int value) { value = 9; }
        private static int Read(in int value) => value;
        private static void AssignOut(out int value) { value = 12; }
        private static void EditObject(Box value)
        {
            value.Value = 6;
            value = new Box { Value = 99 };
        }
        private static void EditThroughIn(in Box value) { value.Value = 8; }
        private static void ReplaceBox(ref Box value) { value = new Box { Value = 15 }; }
    }
    
    internal sealed class Box { public int Value { get; set; } }

    Expected stdout:

    Text
    parcel: 1, standard
    parcel: 3, standard
    parcel: 2, rush
    parcel: auto
    parcel: manual
    int overload
    long overload
    ordinary: 4
    ref: 9
    in: 9; caller=9
    out: 12
    ordinary object: 6
    in object: 8
    ref object: 15

    Describe("parcel") omits both optional arguments. The named copies call changes only copies; label remains “standard”. Route’s one-argument overload instead delegates explicitly to its two-argument implementation. Pick(7) selects the int overload; Pick(7L) selects long. Distinct literal types make these calls intentional; avoid APIs whose callers must guess between numeric conversions.

    What happens when a library default changes?

    For an ordinary statically compiled C# call, omitted optional values are supplied by the compiled caller. If Describe’s default changes from 1 to 5 in a replacement library, an already compiled caller still supplies 1; recompiling that caller against the changed declaration supplies 5. This is a versioning scenario, not a binary-replacement experiment performed by the listing.

    Route’s one-argument call invokes an actual overload. Changing that overload’s body from “auto” to “manual” changes its behavior when the new library implementation is loaded, without the caller supplying a baked-in mode. This can be useful, but a behavior change can still surprise callers. Keep old signatures, review compatibility, and test existing callers. Do not generalize the compiled-default rule to dynamic or reflection-based invocation; those binding paths are outside this example.

    Who can change what?

    • Ordinary parameters receive a value copy. For a class, that value is a reference: object mutation can be visible, while parameter reassignment is local.
    • ref requires an initialized caller variable and ref at the call. The method may read or replace that variable.
    • in provides readonly access to the parameter’s storage. The call-site in is optional, and some calls use temporaries. It does not make a referenced object immutable.
    • out permits an uninitialized caller variable; the method must assign it before normal return. Write out at the call site.

    Here ordinary leaves number at 4, ref changes it to 9, in reads 9, and out supplies 12. EditObject changes the shared Box to 6; its replacement with 99 is local. EditThroughIn changes the same object to 8. ReplaceBox finally replaces the caller’s reference with a new box containing 15.

    Choose modifiers for their mutation contract. Do not assume in or ref is universally faster; measure a relevant workload before making a performance claim. For several outputs, consider one clearly named return object rather than multiple out parameters.

    3. Solved practice: replace six primitive parameters

    Exercise: Replace PlanDispatch(string itemCode, int quantity, string destination, bool express, bool giftWrap, string note) with a request record. Validate inputs and return a result callers cannot confuse with a failure.

    Explicit exercise contract: This is a new in-memory dispatch-plan exercise, separate from the retained quote policy. Item and destination must contain non-whitespace text; preserve their text exactly. Quantity is 1 through 10 inclusive. Note must be non-null, may be empty, and has at most 20 UTF-16 code units. Express selects “express” versus “standard”; GiftWrap selects “gift” versus “plain”. All six inputs are required. No shipment is booked.

    The solved version keeps a six-argument adapter for old callers and forwards it to one validation path. The named request call makes the two booleans legible. Success returns a DispatchPlan describing the whole accepted plan; invalid requests throw before any plan is returned. There is no null result or success-looking sentinel. The method does not mutate or retain the request. In this program, constructing a DispatchRequest with Quantity = 0 is possible; PlanDispatch is the validation boundary. These exceptions report violations of this API’s declared caller contract. An application accepting untrusted form input can perform nonthrowing validation first and display errors before calling this API.

    C#
    using System;
    
    internal static class Program
    {
        private static void Main()
        {
            var request = new DispatchRequest(
                ItemCode: "book", Quantity: 2, Destination: "desk",
                Express: true, GiftWrap: false, Note: "");
            Show("request", PlanDispatch(request));
            Show("legacy", PlanDispatch("book", 2, "desk", true, false, ""));
            Show("standard", PlanDispatch(new("pen", 1, "locker", false, true, "hi")));
            Expect<ArgumentNullException>("null-request", () => PlanDispatch(null!));
            Expect<ArgumentNullException>("null-item", () => PlanDispatch(request with { ItemCode = null! }));
            Expect<ArgumentException>("blank-item", () => PlanDispatch(request with { ItemCode = " " }));
            Expect<ArgumentOutOfRangeException>("zero-quantity", () => PlanDispatch(request with { Quantity = 0 }));
            Expect<ArgumentOutOfRangeException>("over-limit", () => PlanDispatch(request with { Quantity = 11 }));
            Expect<ArgumentException>("blank-destination", () => PlanDispatch(request with { Destination = "" }));
            Expect<ArgumentNullException>("null-note", () => PlanDispatch(request with { Note = null! }));
            Expect<ArgumentException>("long-note", () => PlanDispatch(request with { Note = new string('x', 21) }));
        }
    
        // Compatibility adapter: same six inputs, one validation/calculation path.
        private static DispatchPlan PlanDispatch(string itemCode, int quantity,
            string destination, bool express, bool giftWrap, string note)
            => PlanDispatch(new DispatchRequest(itemCode, quantity, destination, express, giftWrap, note));
    
        private static DispatchPlan PlanDispatch(DispatchRequest request)
        {
            ArgumentNullException.ThrowIfNull(request);
            ArgumentException.ThrowIfNullOrWhiteSpace(request.ItemCode);
            if (request.Quantity is < 1 or > 10)
                throw new ArgumentOutOfRangeException(nameof(request), "Quantity must be 1..10.");
            ArgumentException.ThrowIfNullOrWhiteSpace(request.Destination);
            ArgumentNullException.ThrowIfNull(request.Note);
            if (request.Note.Length > 20)
                throw new ArgumentException("Note must have at most 20 UTF-16 code units.", nameof(request));
            return new DispatchPlan(
                request.ItemCode, request.Quantity, request.Destination,
                request.Express ? "express" : "standard",
                request.GiftWrap ? "gift" : "plain", request.Note);
        }
    
        private static void Show(string label, DispatchPlan result)
            => Console.WriteLine(
                $"{label}: {result.ItemCode} x{result.Quantity}; {result.Destination}; " +
                $"{result.Service}; {result.Packaging}; note=[{result.Note}]");
    
        private static void Expect<T>(string label, Action action) where T : Exception
        {
            try { action(); }
            catch (Exception ex) when (ex.GetType() == typeof(T))
            {
                Console.WriteLine($"{label}: {ex.GetType().Name}");
                return;
            }
            throw new Exception($"Missing expected {typeof(T).Name}: {label}");
        }
    }
    
    public sealed record DispatchRequest(
        string ItemCode, int Quantity, string Destination,
        bool Express, bool GiftWrap, string Note);
    public sealed record DispatchPlan(
        string ItemCode, int Quantity, string Destination,
        string Service, string Packaging, string Note);

    Expected stdout:

    Text
    request: book x2; desk; express; plain; note=[]
    legacy: book x2; desk; express; plain; note=[]
    standard: pen x1; locker; standard; gift; note=[hi]
    null-request: ArgumentNullException
    null-item: ArgumentNullException
    blank-item: ArgumentException
    zero-quantity: ArgumentOutOfRangeException
    over-limit: ArgumentOutOfRangeException
    blank-destination: ArgumentException
    null-note: ArgumentNullException
    long-note: ArgumentException

    Solution walkthrough: The first and second lines prove the request call and compatibility adapter produce the same plan for the demonstrated input. The third exercises both opposite boolean choices. The remaining cases identify rejected inputs without relying on localized exception messages. For multiple invalid fields, validation stops at the first failing guard in the shown order: request, item, quantity, destination, note.

    The record is useful here because these six values describe one dispatch request. It is not a reason to pack unrelated inputs into an unstructured “options” bag. If gift wrapping later needs several states, introduce a named domain choice and a migration plan instead of assigning a new secret meaning to false.

    Failure modes to catch in review

    • Unexplained booleans: name the arguments or represent a meaningful domain choice.
    • Defaults that silently change after recompilation: document the policy and test old and rebuilt callers.
    • Overloads relying on surprising conversions: prefer an obvious call shape and retain existing signatures during migration.
    • Ambiguous success: do not use null to represent both “absent” and “failed”. State one result/error convention.
    • Hidden mutation: document whether the method changes an object, replaces a caller variable, or simply returns a new value.

    Interview check — worked answer

    A method signature is a contract because it tells callers which inputs they must provide and how results or writable arguments come back. For CalculateQuote, I would also document its synthetic lookup policy, rounding, exceptions, and no-mutation behavior. To evolve an API, I would retain the old entry point as an adapter, centralize validation, and test both call forms. I would treat optional-default changes as potentially observable after recompilation, and overload additions as changes that deserve call-site compatibility tests.

    Sources

    Language and API semantics checked against Microsoft documentation; the policies, programs, and concrete traces above are authored examples.

    Analogy

    Everyday picture

    Imagine a repair workshop that publishes an order form and a clear promise: which information customers must supply, what an omitted optional choice means, how rejection is reported, what receipt comes back, and whether customer-owned material may be changed or kept. Putting related details on one named form makes their meaning easier to see.

    Mapping. The form represents a method's inputs or request object. The promise represents the full API contract, completed by validation and tests rather than the signature alone.

    Where it stops. A form does not enforce its own rules: creating a request record alone does not make its contents valid. This picture does not explain overload binding, ref/in/out storage access or compiled optional defaults; trace those separately in the code.

    Cheat sheet (PDF)

    csharp-methods-api-contracts-companion.pdf10 pages · 81 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in