Internal vs. Public Retrieval Patterns
Most APIs serve more than one audience. Internal tools need full entity details for debugging and administration, while public consumers need a curated, secure view. This lesson covers the patterns for designing these dual retrieval endpoints, calculated DTO fields, and standardized error responses.
Two Audiences, Two Endpoints
Consider a parcel tracking system. An internal operations dashboard needs everything: database IDs, shipper contact details, recipient addresses, timestamps, and audit fields. A public tracking page only needs the tracking number, current status, and delivery timeline. Exposing internal data publicly creates security and privacy risks.
The standard approach is to build separate endpoints:
1GET /api/parcels/{id} → Internal (full details)2GET /api/tracking/{trackingNumber} → Public (simplified view)
Each endpoint returns a different response DTO shaped for its audience. The internal endpoint uses authentication and authorization. The public endpoint is open and strips sensitive information.
Designing Response DTOs
A DTO (Data Transfer Object) controls what data leaves your API. Instead of returning your EF Core entity directly, you project into a purpose-built class:
csharp1// Internal DTO - full details for operations team2public class ParcelDetailResponse3{4 public Guid Id { get; set; }5 public string TrackingNumber { get; set; } = string.Empty;6 public string Status { get; set; } = string.Empty;7 public AddressResponse ShipperAddress { get; set; } = null!;8 public AddressResponse RecipientAddress { get; set; } = null!;9 public decimal Weight { get; set; }10 public string WeightUnit { get; set; } = string.Empty;11 public DateTime CreatedAt { get; set; }12 public DateTime? DeliveredAt { get; set; }13 public int DaysInTransit { get; set; }14 public bool IsDelivered { get; set; }15}1617// Public DTO - safe for external consumers18public class TrackingResponse19{20 public string TrackingNumber { get; set; } = string.Empty;21 public string Status { get; set; } = string.Empty;22 public string RecipientCity { get; set; } = string.Empty;23 public string RecipientState { get; set; } = string.Empty;24 public int DaysInTransit { get; set; }25 public bool IsDelivered { get; set; }26}
Notice the public DTO omits the database Id, shipper contact details, and the full recipient address. It provides just enough information for a customer to check their package status.
Calculated Fields in DTOs
Not every field in a response maps directly to a database column. Calculated fields derive their value from other data at response time:
csharp1DaysInTransit = (int)(DateTime.UtcNow - parcel.CreatedAt).TotalDays;2IsDelivered = parcel.Status == ParcelStatus.Delivered;
These fields improve the consumer experience. Without DaysInTransit, every client would have to perform the same date arithmetic. Without IsDelivered, every client would need to know which status string means "delivered."
Guidelines for calculated fields:
- Keep them deterministic -- the same input always produces the same output
- Compute them during mapping -- not in the entity or database
- Document them -- API consumers should know these are derived, not stored
Eager Loading Related Data
Both retrieval endpoints need related address data. Without eager loading, EF Core returns null for navigation properties:
csharp1// Without Include - addresses are null2var parcel = await context.Parcels.FindAsync(id);3// parcel.ShipperAddress is null!45// With Include - addresses are loaded6var parcel = await context.Parcels7 .Include(p => p.ShipperAddress)8 .Include(p => p.RecipientAddress)9 .FirstOrDefaultAsync(p => p.Id == id);10// parcel.ShipperAddress is populated
Use Include() to load the navigation properties your DTO mapping needs. Only include what you actually use -- unnecessary includes waste database resources.
RFC 7807 Problem Details
When a parcel is not found, the API needs a clear, standardized error format. RFC 7807 defines the Problem Details specification, which ASP.NET Core supports natively through the ProblemDetails class:
json1{2 "type": "https://tools.ietf.org/html/rfc7807",3 "title": "Parcel Not Found",4 "status": 404,5 "detail": "No parcel exists with ID '3fa85f64-5717-4562-b3fc-2c963f66afa6'.",6 "instance": "/api/parcels/3fa85f64-5717-4562-b3fc-2c963f66afa6"7}
Problem Details provides a machine-readable structure with consistent fields:
- type -- a URI identifying the error category
- title -- a short, human-readable summary
- status -- the HTTP status code
- detail -- a specific explanation of what went wrong
- instance -- the URI of the request that caused the error
ASP.NET Core's Results.Problem() and ControllerBase.Problem() methods generate this format automatically. This is far better than ad-hoc error shapes like { "error": "not found" } that vary across endpoints.
Real-World Examples
Major carrier APIs follow this same internal/public split pattern. Consider how tracking works in practice:
- FedEx Track API -- the public tracking response returns status, estimated delivery, and city-level location. It never exposes the sender's full address or the internal shipment ID used by FedEx's operations systems.
- UPS Tracking -- the public-facing response includes service type, package weight, and delivery status. Internal systems see routing codes, sort facility assignments, and driver route identifiers.
- USPS Informed Delivery -- consumers see a simplified view of their incoming mail. Postal workers see full routing barcodes, distribution center assignments, and carrier route data.
The pattern is universal: public consumers see a curated subset, internal systems see everything.
Choosing the Right Lookup Key
Each endpoint uses a different lookup key suited to its audience:
| Key Type | Example | Audience | Properties |
|---|---|---|---|
| Database ID (GUID) | 3fa85f64-5717-... | Internal | Globally unique, not human-readable |
| Tracking Number | PKG-20260215-A1B2C3 | Public | Human-readable, typed on forms |
The database ID is ideal for internal cross-referencing between microservices and admin tools. The tracking number is designed for humans -- it can be printed on labels, read over the phone, and entered into web forms. Using the right key for each audience simplifies the consumer experience.
Why Separate Endpoints Matter
Combining internal and public data into a single endpoint with conditional field hiding leads to fragile, hard-to-secure code. Separate endpoints provide:
- Clear authorization boundaries -- the internal endpoint requires authentication, the public one does not
- Independent evolution -- internal details can change without breaking public consumers
- Simpler testing -- each endpoint has a focused contract
- Better documentation -- OpenAPI specs clearly show what each audience receives
- Performance optimization -- each endpoint loads only the related data its DTO requires
Summary
In this lesson, you learned:
- Internal and public API consumers need different response shapes for security and usability
- DTOs control which entity fields are exposed to each audience
- Calculated fields like
DaysInTransitandIsDeliveredare computed during mapping, not stored - Eager loading with
Include()loads related entities needed for DTO projection - RFC 7807 Problem Details provides a standardized, machine-readable error format
- Separate endpoints are cleaner and more secure than conditional field hiding
Next, we will implement the GET-by-ID and GET-by-tracking-number endpoints with their respective DTOs.