dotnet-backend-patterns

安装量: 5.3K
排名: #571

安装

npx skills add https://github.com/wshobson/agents --skill dotnet-backend-patterns
.NET Backend Development Patterns
Master C#/.NET patterns for building production-grade APIs, MCP servers, and enterprise backends with modern best practices (2024/2025).
When to Use This Skill
Developing new .NET Web APIs or MCP servers
Reviewing C# code for quality and performance
Designing service architectures with dependency injection
Implementing caching strategies with Redis
Writing unit and integration tests
Optimizing database access with EF Core or Dapper
Configuring applications with IOptions pattern
Handling errors and implementing resilience patterns
Core Concepts
1. Project Structure (Clean Architecture)
src/
├── Domain/ # Core business logic (no dependencies)
│ ├── Entities/
│ ├── Interfaces/
│ ├── Exceptions/
│ └── ValueObjects/
├── Application/ # Use cases, DTOs, validation
│ ├── Services/
│ ├── DTOs/
│ ├── Validators/
│ └── Interfaces/
├── Infrastructure/ # External implementations
│ ├── Data/ # EF Core, Dapper repositories
│ ├── Caching/ # Redis, Memory cache
│ ├── External/ # HTTP clients, third-party APIs
│ └── DependencyInjection/ # Service registration
└── Api/ # Entry point
├── Controllers/ # Or MinimalAPI endpoints
├── Middleware/
├── Filters/
└── Program.cs
2. Dependency Injection Patterns
// Service registration by lifetime
public
static
class
ServiceCollectionExtensions
{
public
static
IServiceCollection
AddApplicationServices
(
this
IServiceCollection
services
,
IConfiguration
configuration
)
{
// Scoped: One instance per HTTP request
services
.
AddScoped
<
IProductService
,
ProductService
>
(
)
;
services
.
AddScoped
<
IOrderService
,
OrderService
>
(
)
;
// Singleton: One instance for app lifetime
services
.
AddSingleton
<
ICacheService
,
RedisCacheService
>
(
)
;
services
.
AddSingleton
<
IConnectionMultiplexer
>
(
_
=>
ConnectionMultiplexer
.
Connect
(
configuration
[
"Redis:Connection"
]
!
)
)
;
// Transient: New instance every time
services
.
AddTransient
<
IValidator
<
CreateOrderRequest
>
,
CreateOrderValidator
>
(
)
;
// Options pattern for configuration
services
.
Configure
<
CatalogOptions
>
(
configuration
.
GetSection
(
"Catalog"
)
)
;
services
.
Configure
<
RedisOptions
>
(
configuration
.
GetSection
(
"Redis"
)
)
;
// Factory pattern for conditional creation
services
.
AddScoped
<
IPriceCalculator
>
(
sp
=>
{
var
options
=
sp
.
GetRequiredService
<
IOptions
<
PricingOptions
>
>
(
)
.
Value
;
return
options
.
UseNewEngine
?
sp
.
GetRequiredService
<
NewPriceCalculator
>
(
)
:
sp
.
GetRequiredService
<
LegacyPriceCalculator
>
(
)
;
}
)
;
// Keyed services (.NET 8+)
services
.
AddKeyedScoped
<
IPaymentProcessor
,
StripeProcessor
>
(
"stripe"
)
;
services
.
AddKeyedScoped
<
IPaymentProcessor
,
PayPalProcessor
>
(
"paypal"
)
;
return
services
;
}
}
// Usage with keyed services
public
class
CheckoutService
{
public
CheckoutService
(
[
FromKeyedServices
(
"stripe"
)
]
IPaymentProcessor
stripeProcessor
)
{
_processor
=
stripeProcessor
;
}
}
3. Async/Await Patterns
// ✅ CORRECT: Async all the way down
public
async
Task
<
Product
>
GetProductAsync
(
string
id
,
CancellationToken
ct
=
default
)
{
return
await
_repository
.
GetByIdAsync
(
id
,
ct
)
;
}
// ✅ CORRECT: Parallel execution with WhenAll
public
async
Task
<
(
Stock
,
Price
)
>
GetStockAndPriceAsync
(
string
productId
,
CancellationToken
ct
=
default
)
{
var
stockTask
=
_stockService
.
GetAsync
(
productId
,
ct
)
;
var
priceTask
=
_priceService
.
GetAsync
(
productId
,
ct
)
;
await
Task
.
WhenAll
(
stockTask
,
priceTask
)
;
return
(
await
stockTask
,
await
priceTask
)
;
}
// ✅ CORRECT: ConfigureAwait in libraries
public
async
Task
<
T
>
LibraryMethodAsync
<
T
>
(
CancellationToken
ct
=
default
)
{
var
result
=
await
_httpClient
.
GetAsync
(
url
,
ct
)
.
ConfigureAwait
(
false
)
;
return
await
result
.
Content
.
ReadFromJsonAsync
<
T
>
(
ct
)
.
ConfigureAwait
(
false
)
;
}
// ✅ CORRECT: ValueTask for hot paths with caching
public
ValueTask
<
Product
?
>
GetCachedProductAsync
(
string
id
)
{
if
(
_cache
.
TryGetValue
(
id
,
out
Product
?
product
)
)
return
ValueTask
.
FromResult
(
product
)
;
return
new
ValueTask
<
Product
?
>
(
GetFromDatabaseAsync
(
id
)
)
;
}
// ❌ WRONG: Blocking on async (deadlock risk)
var
result
=
GetProductAsync
(
id
)
.
Result
;
// NEVER do this
var
result2
=
GetProductAsync
(
id
)
.
GetAwaiter
(
)
.
GetResult
(
)
;
// Also bad
// ❌ WRONG: async void (except event handlers)
public
async
void
ProcessOrder
(
)
{
}
// Exceptions are lost
// ❌ WRONG: Unnecessary Task.Run for already async code
await
Task
.
Run
(
async
(
)
=>
await
GetDataAsync
(
)
)
;
// Wastes thread
4. Configuration with IOptions
// Configuration classes
public
class
CatalogOptions
{
public
const
string
SectionName
=
"Catalog"
;
public
int
DefaultPageSize
{
get
;
set
;
}
=
50
;
public
int
MaxPageSize
{
get
;
set
;
}
=
200
;
public
TimeSpan
CacheDuration
{
get
;
set
;
}
=
TimeSpan
.
FromMinutes
(
15
)
;
public
bool
EnableEnrichment
{
get
;
set
;
}
=
true
;
}
public
class
RedisOptions
{
public
const
string
SectionName
=
"Redis"
;
public
string
Connection
{
get
;
set
;
}
=
"localhost:6379"
;
public
string
KeyPrefix
{
get
;
set
;
}
=
"mcp:"
;
public
int
Database
{
get
;
set
;
}
=
0
;
}
// appsettings.json
{
"Catalog"
:
{
"DefaultPageSize"
:
50
,
"MaxPageSize"
:
200
,
"CacheDuration"
:
"00:15:00"
,
"EnableEnrichment"
:
true
}
,
"Redis"
:
{
"Connection"
:
"localhost:6379"
,
"KeyPrefix"
:
"mcp:"
,
"Database"
:
0
}
}
// Registration
services
.
Configure
<
CatalogOptions
>
(
configuration
.
GetSection
(
CatalogOptions
.
SectionName
)
)
;
services
.
Configure
<
RedisOptions
>
(
configuration
.
GetSection
(
RedisOptions
.
SectionName
)
)
;
// Usage with IOptions (singleton, read once at startup)
public
class
CatalogService
{
private
readonly
CatalogOptions
_options
;
public
CatalogService
(
IOptions
<
CatalogOptions
>
options
)
{
_options
=
options
.
Value
;
}
}
// Usage with IOptionsSnapshot (scoped, re-reads on each request)
public
class
DynamicService
{
private
readonly
CatalogOptions
_options
;
public
DynamicService
(
IOptionsSnapshot
<
CatalogOptions
>
options
)
{
_options
=
options
.
Value
;
// Fresh value per request
}
}
// Usage with IOptionsMonitor (singleton, notified on changes)
public
class
MonitoredService
{
private
CatalogOptions
_options
;
public
MonitoredService
(
IOptionsMonitor
<
CatalogOptions
>
monitor
)
{
_options
=
monitor
.
CurrentValue
;
monitor
.
OnChange
(
newOptions
=>
_options
=
newOptions
)
;
}
}
5. Result Pattern (Avoiding Exceptions for Flow Control)
// Generic Result type
public
class
Result
<
T
>
{
public
bool
IsSuccess
{
get
;
}
public
T
?
Value
{
get
;
}
public
string
?
Error
{
get
;
}
public
string
?
ErrorCode
{
get
;
}
private
Result
(
bool
isSuccess
,
T
?
value
,
string
?
error
,
string
?
errorCode
)
{
IsSuccess
=
isSuccess
;
Value
=
value
;
Error
=
error
;
ErrorCode
=
errorCode
;
}
public
static
Result
<
T
>
Success
(
T
value
)
=>
new
(
true
,
value
,
null
,
null
)
;
public
static
Result
<
T
>
Failure
(
string
error
,
string
?
code
=
null
)
=>
new
(
false
,
default
,
error
,
code
)
;
public
Result
<
TNew
>
Map
<
TNew
>
(
Func
<
T
,
TNew
>
mapper
)
=>
IsSuccess
?
Result
<
TNew
>
.
Success
(
mapper
(
Value
!
)
)
:
Result
<
TNew
>
.
Failure
(
Error
!
,
ErrorCode
)
;
public
async
Task
<
Result
<
TNew
>
>
MapAsync
<
TNew
>
(
Func
<
T
,
Task
<
TNew
>
>
mapper
)
=>
IsSuccess
?
Result
<
TNew
>
.
Success
(
await
mapper
(
Value
!
)
)
:
Result
<
TNew
>
.
Failure
(
Error
!
,
ErrorCode
)
;
}
// Usage in service
public
async
Task
<
Result
<
Order
>
>
CreateOrderAsync
(
CreateOrderRequest
request
,
CancellationToken
ct
)
{
// Validation
var
validation
=
await
_validator
.
ValidateAsync
(
request
,
ct
)
;
if
(
!
validation
.
IsValid
)
return
Result
<
Order
>
.
Failure
(
validation
.
Errors
.
First
(
)
.
ErrorMessage
,
"VALIDATION_ERROR"
)
;
// Business rule check
var
stock
=
await
_stockService
.
CheckAsync
(
request
.
ProductId
,
request
.
Quantity
,
ct
)
;
if
(
!
stock
.
IsAvailable
)
return
Result
<
Order
>
.
Failure
(
$"Insufficient stock:
{
stock
.
Available
}
available,
{
request
.
Quantity
}
requested"
,
"INSUFFICIENT_STOCK"
)
;
// Create order
var
order
=
await
_repository
.
CreateAsync
(
request
.
ToEntity
(
)
,
ct
)
;
return
Result
<
Order
>
.
Success
(
order
)
;
}
// Usage in controller/endpoint
app
.
MapPost
(
"/orders"
,
async
(
CreateOrderRequest
request
,
IOrderService
orderService
,
CancellationToken
ct
)
=>
{
var
result
=
await
orderService
.
CreateOrderAsync
(
request
,
ct
)
;
return
result
.
IsSuccess
?
Results
.
Created
(
$"/orders/
{
result
.
Value
!
.
Id
}
"
,
result
.
Value
)
:
Results
.
BadRequest
(
new
{
error
=
result
.
Error
,
code
=
result
.
ErrorCode
}
)
;
}
)
;
Data Access Patterns
Entity Framework Core
// DbContext configuration
public
class
AppDbContext
:
DbContext
{
public
DbSet
<
Product
>
Products
=>
Set
<
Product
>
(
)
;
public
DbSet
<
Order
>
Orders
=>
Set
<
Order
>
(
)
;
protected
override
void
OnModelCreating
(
ModelBuilder
modelBuilder
)
{
// Apply all configurations from assembly
modelBuilder
.
ApplyConfigurationsFromAssembly
(
typeof
(
AppDbContext
)
.
Assembly
)
;
// Global query filters
modelBuilder
.
Entity
<
Product
>
(
)
.
HasQueryFilter
(
p
=>
!
p
.
IsDeleted
)
;
}
}
// Entity configuration
public
class
ProductConfiguration
:
IEntityTypeConfiguration
<
Product
>
{
public
void
Configure
(
EntityTypeBuilder
<
Product
>
builder
)
{
builder
.
ToTable
(
"Products"
)
;
builder
.
HasKey
(
p
=>
p
.
Id
)
;
builder
.
Property
(
p
=>
p
.
Id
)
.
HasMaxLength
(
40
)
;
builder
.
Property
(
p
=>
p
.
Name
)
.
HasMaxLength
(
200
)
.
IsRequired
(
)
;
builder
.
Property
(
p
=>
p
.
Price
)
.
HasPrecision
(
18
,
2
)
;
builder
.
HasIndex
(
p
=>
p
.
Sku
)
.
IsUnique
(
)
;
builder
.
HasIndex
(
p
=>
new
{
p
.
CategoryId
,
p
.
Name
}
)
;
builder
.
HasMany
(
p
=>
p
.
OrderItems
)
.
WithOne
(
oi
=>
oi
.
Product
)
.
HasForeignKey
(
oi
=>
oi
.
ProductId
)
;
}
}
// Repository with EF Core
public
class
ProductRepository
:
IProductRepository
{
private
readonly
AppDbContext
_context
;
public
async
Task
<
Product
?
>
GetByIdAsync
(
string
id
,
CancellationToken
ct
=
default
)
{
return
await
_context
.
Products
.
AsNoTracking
(
)
.
FirstOrDefaultAsync
(
p
=>
p
.
Id
==
id
,
ct
)
;
}
public
async
Task
<
IReadOnlyList
<
Product
>
>
SearchAsync
(
ProductSearchCriteria
criteria
,
CancellationToken
ct
=
default
)
{
var
query
=
_context
.
Products
.
AsNoTracking
(
)
;
if
(
!
string
.
IsNullOrWhiteSpace
(
criteria
.
SearchTerm
)
)
query
=
query
.
Where
(
p
=>
EF
.
Functions
.
Like
(
p
.
Name
,
$"%
{
criteria
.
SearchTerm
}
%"
)
)
;
if
(
criteria
.
CategoryId
.
HasValue
)
query
=
query
.
Where
(
p
=>
p
.
CategoryId
==
criteria
.
CategoryId
)
;
if
(
criteria
.
MinPrice
.
HasValue
)
query
=
query
.
Where
(
p
=>
p
.
Price
>=
criteria
.
MinPrice
)
;
if
(
criteria
.
MaxPrice
.
HasValue
)
query
=
query
.
Where
(
p
=>
p
.
Price
<=
criteria
.
MaxPrice
)
;
return
await
query
.
OrderBy
(
p
=>
p
.
Name
)
.
Skip
(
(
criteria
.
Page
-
1
)
*
criteria
.
PageSize
)
.
Take
(
criteria
.
PageSize
)
.
ToListAsync
(
ct
)
;
}
}
Dapper for Performance
public
class
DapperProductRepository
:
IProductRepository
{
private
readonly
IDbConnection
_connection
;
public
async
Task
<
Product
?
>
GetByIdAsync
(
string
id
,
CancellationToken
ct
=
default
)
{
const
string
sql
=
""
"
SELECT
Id
,
Name
,
Sku
,
Price
,
CategoryId
,
Stock
,
CreatedAt
FROM Products
WHERE
Id
=
@Id
AND
IsDeleted
=
0
""
"
;
return
await
_connection
.
QueryFirstOrDefaultAsync
<
Product
>
(
new
CommandDefinition
(
sql
,
new
{
Id
=
id
}
,
cancellationToken
:
ct
)
)
;
}
public
async
Task
<
IReadOnlyList
<
Product
>
>
SearchAsync
(
ProductSearchCriteria
criteria
,
CancellationToken
ct
=
default
)
{
var
sql
=
new
StringBuilder
(
""
"
SELECT
Id
,
Name
,
Sku
,
Price
,
CategoryId
,
Stock
,
CreatedAt
FROM Products
WHERE
IsDeleted
=
0
""
"
)
;
var
parameters
=
new
DynamicParameters
(
)
;
if
(
!
string
.
IsNullOrWhiteSpace
(
criteria
.
SearchTerm
)
)
{
sql
.
Append
(
" AND Name LIKE @SearchTerm"
)
;
parameters
.
Add
(
"SearchTerm"
,
$"%
{
criteria
.
SearchTerm
}
%"
)
;
}
if
(
criteria
.
CategoryId
.
HasValue
)
{
sql
.
Append
(
" AND CategoryId = @CategoryId"
)
;
parameters
.
Add
(
"CategoryId"
,
criteria
.
CategoryId
)
;
}
if
(
criteria
.
MinPrice
.
HasValue
)
{
sql
.
Append
(
" AND Price >= @MinPrice"
)
;
parameters
.
Add
(
"MinPrice"
,
criteria
.
MinPrice
)
;
}
if
(
criteria
.
MaxPrice
.
HasValue
)
{
sql
.
Append
(
" AND Price <= @MaxPrice"
)
;
parameters
.
Add
(
"MaxPrice"
,
criteria
.
MaxPrice
)
;
}
sql
.
Append
(
" ORDER BY Name OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY"
)
;
parameters
.
Add
(
"Offset"
,
(
criteria
.
Page
-
1
)
*
criteria
.
PageSize
)
;
parameters
.
Add
(
"PageSize"
,
criteria
.
PageSize
)
;
var
results
=
await
_connection
.
QueryAsync
<
Product
>
(
new
CommandDefinition
(
sql
.
ToString
(
)
,
parameters
,
cancellationToken
:
ct
)
)
;
return
results
.
ToList
(
)
;
}
// Multi-mapping for related data
public
async
Task
<
Order
?
>
GetOrderWithItemsAsync
(
int
orderId
,
CancellationToken
ct
=
default
)
{
const
string
sql
=
""
"
SELECT o
.
*
,
oi
.
*
,
p
.
*
FROM Orders o
LEFT JOIN OrderItems oi ON o
.
Id
=
oi
.
OrderId
LEFT JOIN Products p ON oi
.
ProductId
=
p
.
Id
WHERE o
.
Id
=
@OrderId
""
"
;
var
orderDictionary
=
new
Dictionary
<
int
,
Order
>
(
)
;
await
_connection
.
QueryAsync
<
Order
,
OrderItem
,
Product
,
Order
>
(
new
CommandDefinition
(
sql
,
new
{
OrderId
=
orderId
}
,
cancellationToken
:
ct
)
,
(
order
,
item
,
product
)
=>
{
if
(
!
orderDictionary
.
TryGetValue
(
order
.
Id
,
out
var
existingOrder
)
)
{
existingOrder
=
order
;
existingOrder
.
Items
=
new
List
<
OrderItem
>
(
)
;
orderDictionary
.
Add
(
order
.
Id
,
existingOrder
)
;
}
if
(
item
!=
null
)
{
item
.
Product
=
product
;
existingOrder
.
Items
.
Add
(
item
)
;
}
return
existingOrder
;
}
,
splitOn
:
"Id,Id"
)
;
return
orderDictionary
.
Values
.
FirstOrDefault
(
)
;
}
}
Caching Patterns
Multi-Level Cache with Redis
public
class
CachedProductService
:
IProductService
{
private
readonly
IProductRepository
_repository
;
private
readonly
IMemoryCache
_memoryCache
;
private
readonly
IDistributedCache
_distributedCache
;
private
readonly
ILogger
<
CachedProductService
>
_logger
;
private
static
readonly
TimeSpan
MemoryCacheDuration
=
TimeSpan
.
FromMinutes
(
1
)
;
private
static
readonly
TimeSpan
DistributedCacheDuration
=
TimeSpan
.
FromMinutes
(
15
)
;
public
async
Task
<
Product
?
>
GetByIdAsync
(
string
id
,
CancellationToken
ct
=
default
)
{
var
cacheKey
=
$"product:
{
id
}
"
;
// L1: Memory cache (in-process, fastest)
if
(
_memoryCache
.
TryGetValue
(
cacheKey
,
out
Product
?
cached
)
)
{
_logger
.
LogDebug
(
"L1 cache hit for {CacheKey}"
,
cacheKey
)
;
return
cached
;
}
// L2: Distributed cache (Redis)
var
distributed
=
await
_distributedCache
.
GetStringAsync
(
cacheKey
,
ct
)
;
if
(
distributed
!=
null
)
{
_logger
.
LogDebug
(
"L2 cache hit for {CacheKey}"
,
cacheKey
)
;
var
product
=
JsonSerializer
.
Deserialize
<
Product
>
(
distributed
)
;
// Populate L1
_memoryCache
.
Set
(
cacheKey
,
product
,
MemoryCacheDuration
)
;
return
product
;
}
// L3: Database
_logger
.
LogDebug
(
"Cache miss for {CacheKey}, fetching from database"
,
cacheKey
)
;
var
fromDb
=
await
_repository
.
GetByIdAsync
(
id
,
ct
)
;
if
(
fromDb
!=
null
)
{
var
serialized
=
JsonSerializer
.
Serialize
(
fromDb
)
;
// Populate both caches
await
_distributedCache
.
SetStringAsync
(
cacheKey
,
serialized
,
new
DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow
=
DistributedCacheDuration
}
,
ct
)
;
_memoryCache
.
Set
(
cacheKey
,
fromDb
,
MemoryCacheDuration
)
;
}
return
fromDb
;
}
public
async
Task
InvalidateAsync
(
string
id
,
CancellationToken
ct
=
default
)
{
var
cacheKey
=
$"product:
{
id
}
"
;
_memoryCache
.
Remove
(
cacheKey
)
;
await
_distributedCache
.
RemoveAsync
(
cacheKey
,
ct
)
;
_logger
.
LogInformation
(
"Invalidated cache for {CacheKey}"
,
cacheKey
)
;
}
}
// Stale-while-revalidate pattern
public
class
StaleWhileRevalidateCache
<
T
>
{
private
readonly
IDistributedCache
_cache
;
private
readonly
TimeSpan
_freshDuration
;
private
readonly
TimeSpan
_staleDuration
;
public
async
Task
<
T
?
>
GetOrCreateAsync
(
string
key
,
Func
<
CancellationToken
,
Task
<
T
>
>
factory
,
CancellationToken
ct
=
default
)
{
var
cached
=
await
_cache
.
GetStringAsync
(
key
,
ct
)
;
if
(
cached
!=
null
)
{
var
entry
=
JsonSerializer
.
Deserialize
<
CacheEntry
<
T
>
>
(
cached
)
!
;
if
(
entry
.
IsStale
&&
!
entry
.
IsExpired
)
{
// Return stale data immediately, refresh in background
_
=
Task
.
Run
(
async
(
)
=>
{
var
fresh
=
await
factory
(
CancellationToken
.
None
)
;
await
SetAsync
(
key
,
fresh
,
CancellationToken
.
None
)
;
}
)
;
}
if
(
!
entry
.
IsExpired
)
return
entry
.
Value
;
}
// Cache miss or expired
var
value
=
await
factory
(
ct
)
;
await
SetAsync
(
key
,
value
,
ct
)
;
return
value
;
}
private
record
CacheEntry
<
TValue
>
(
TValue
Value
,
DateTime
CreatedAt
)
{
public
bool
IsStale
=>
DateTime
.
UtcNow
-
CreatedAt
>
_freshDuration
;
public
bool
IsExpired
=>
DateTime
.
UtcNow
-
CreatedAt
>
_staleDuration
;
}
}
Testing Patterns
Unit Tests with xUnit and Moq
public
class
OrderServiceTests
{
private
readonly
Mock
<
IOrderRepository
>
_mockRepository
;
private
readonly
Mock
<
IStockService
>
_mockStockService
;
private
readonly
Mock
<
IValidator
<
CreateOrderRequest
>
>
_mockValidator
;
private
readonly
OrderService
_sut
;
// System Under Test
public
OrderServiceTests
(
)
{
_mockRepository
=
new
Mock
<
IOrderRepository
>
(
)
;
_mockStockService
=
new
Mock
<
IStockService
>
(
)
;
_mockValidator
=
new
Mock
<
IValidator
<
CreateOrderRequest
>
>
(
)
;
// Default: validation passes
_mockValidator
.
Setup
(
v
=>
v
.
ValidateAsync
(
It
.
IsAny
<
CreateOrderRequest
>
(
)
,
It
.
IsAny
<
CancellationToken
>
(
)
)
)
.
ReturnsAsync
(
new
ValidationResult
(
)
)
;
_sut
=
new
OrderService
(
_mockRepository
.
Object
,
_mockStockService
.
Object
,
_mockValidator
.
Object
)
;
}
[
Fact
]
public
async
Task
CreateOrderAsync_WithValidRequest_ReturnsSuccess
(
)
{
// Arrange
var
request
=
new
CreateOrderRequest
{
ProductId
=
"PROD-001"
,
Quantity
=
5
,
CustomerOrderCode
=
"ORD-2024-001"
}
;
_mockStockService
.
Setup
(
s
=>
s
.
CheckAsync
(
"PROD-001"
,
5
,
It
.
IsAny
<
CancellationToken
>
(
)
)
)
.
ReturnsAsync
(
new
StockResult
{
IsAvailable
=
true
,
Available
=
10
}
)
;
_mockRepository
.
Setup
(
r
=>
r
.
CreateAsync
(
It
.
IsAny
<
Order
>
(
)
,
It
.
IsAny
<
CancellationToken
>
(
)
)
)
.
ReturnsAsync
(
new
Order
{
Id
=
1
,
CustomerOrderCode
=
"ORD-2024-001"
}
)
;
// Act
var
result
=
await
_sut
.
CreateOrderAsync
(
request
)
;
// Assert
Assert
.
True
(
result
.
IsSuccess
)
;
Assert
.
NotNull
(
result
.
Value
)
;
Assert
.
Equal
(
1
,
result
.
Value
.
Id
)
;
_mockRepository
.
Verify
(
r
=>
r
.
CreateAsync
(
It
.
Is
<
Order
>
(
o
=>
o
.
CustomerOrderCode
==
"ORD-2024-001"
)
,
It
.
IsAny
<
CancellationToken
>
(
)
)
,
Times
.
Once
)
;
}
[
Fact
]
public
async
Task
CreateOrderAsync_WithInsufficientStock_ReturnsFailure
(
)
{
// Arrange
var
request
=
new
CreateOrderRequest
{
ProductId
=
"PROD-001"
,
Quantity
=
100
}
;
_mockStockService
.
Setup
(
s
=>
s
.
CheckAsync
(
It
.
IsAny
<
string
>
(
)
,
It
.
IsAny
<
int
>
(
)
,
It
.
IsAny
<
CancellationToken
>
(
)
)
)
.
ReturnsAsync
(
new
StockResult
{
IsAvailable
=
false
,
Available
=
5
}
)
;
// Act
var
result
=
await
_sut
.
CreateOrderAsync
(
request
)
;
// Assert
Assert
.
False
(
result
.
IsSuccess
)
;
Assert
.
Equal
(
"INSUFFICIENT_STOCK"
,
result
.
ErrorCode
)
;
Assert
.
Contains
(
"5 available"
,
result
.
Error
)
;
_mockRepository
.
Verify
(
r
=>
r
.
CreateAsync
(
It
.
IsAny
<
Order
>
(
)
,
It
.
IsAny
<
CancellationToken
>
(
)
)
,
Times
.
Never
)
;
}
[
Theory
]
[
InlineData
(
0
)
]
[
InlineData
(
-
1
)
]
[
InlineData
(
-
100
)
]
public
async
Task
CreateOrderAsync_WithInvalidQuantity_ReturnsValidationError
(
int
quantity
)
{
// Arrange
var
request
=
new
CreateOrderRequest
{
ProductId
=
"PROD-001"
,
Quantity
=
quantity
}
;
_mockValidator
.
Setup
(
v
=>
v
.
ValidateAsync
(
request
,
It
.
IsAny
<
CancellationToken
>
(
)
)
)
.
ReturnsAsync
(
new
ValidationResult
(
new
[
]
{
new
ValidationFailure
(
"Quantity"
,
"Quantity must be greater than 0"
)
}
)
)
;
// Act
var
result
=
await
_sut
.
CreateOrderAsync
(
request
)
;
// Assert
Assert
.
False
(
result
.
IsSuccess
)
;
Assert
.
Equal
(
"VALIDATION_ERROR"
,
result
.
ErrorCode
)
;
}
}
Integration Tests with WebApplicationFactory
public
class
ProductsApiTests
:
IClassFixture
<
WebApplicationFactory
<
Program
>
>
{
private
readonly
WebApplicationFactory
<
Program
>
_factory
;
private
readonly
HttpClient
_client
;
public
ProductsApiTests
(
WebApplicationFactory
<
Program
>
factory
)
{
_factory
=
factory
.
WithWebHostBuilder
(
builder
=>
{
builder
.
ConfigureServices
(
services
=>
{
// Replace real database with in-memory
services
.
RemoveAll
<
DbContextOptions
<
AppDbContext
>
>
(
)
;
services
.
AddDbContext
<
AppDbContext
>
(
options
=>
options
.
UseInMemoryDatabase
(
"TestDb"
)
)
;
// Replace Redis with memory cache
services
.
RemoveAll
<
IDistributedCache
>
(
)
;
services
.
AddDistributedMemoryCache
(
)
;
}
)
;
}
)
;
_client
=
_factory
.
CreateClient
(
)
;
}
[
Fact
]
public
async
Task
GetProduct_WithValidId_ReturnsProduct
(
)
{
// Arrange
using
var
scope
=
_factory
.
Services
.
CreateScope
(
)
;
var
context
=
scope
.
ServiceProvider
.
GetRequiredService
<
AppDbContext
>
(
)
;
context
.
Products
.
Add
(
new
Product
{
Id
=
"TEST-001"
,
Name
=
"Test Product"
,
Price
=
99.99m
}
)
;
await
context
.
SaveChangesAsync
(
)
;
// Act
var
response
=
await
_client
.
GetAsync
(
"/api/products/TEST-001"
)
;
// Assert
response
.
EnsureSuccessStatusCode
(
)
;
var
product
=
await
response
.
Content
.
ReadFromJsonAsync
<
Product
>
(
)
;
Assert
.
Equal
(
"Test Product"
,
product
!
.
Name
)
;
}
[
Fact
]
public
async
Task
GetProduct_WithInvalidId_Returns404
(
)
{
// Act
var
response
=
await
_client
.
GetAsync
(
"/api/products/NONEXISTENT"
)
;
// Assert
Assert
.
Equal
(
HttpStatusCode
.
NotFound
,
response
.
StatusCode
)
;
}
}
Best Practices
DO
Use async/await
all the way through the call stack
Inject dependencies
through constructor injection
Use IOptions
for typed configuration
Return Result types
instead of throwing exceptions for business logic
Use CancellationToken
in all async methods
Prefer Dapper
for read-heavy, performance-critical queries
Use EF Core
for complex domain models with change tracking
Cache aggressively
with proper invalidation strategies
Write unit tests
for business logic, integration tests for APIs
Use record types
for DTOs and immutable data
DON'T
Don't block on async
with
.Result
or
.Wait()
Don't use async void
except for event handlers
Don't catch generic Exception
without re-throwing or logging
Don't hardcode
configuration values
Don't expose EF entities
directly in APIs (use DTOs)
Don't forget
AsNoTracking()
for read-only queries
Don't ignore
CancellationToken parameters
Don't create
new HttpClient()
manually (use IHttpClientFactory)
Don't mix
sync and async code unnecessarily
Don't skip
validation at API boundaries
Common Pitfalls
N+1 Queries
Use
.Include()
or explicit joins
Memory Leaks
Dispose IDisposable resources, use
using
Deadlocks
Don't mix sync and async, use ConfigureAwait(false) in libraries
Over-fetching
Select only needed columns, use projections
Missing Indexes
Check query plans, add indexes for common filters
Timeout Issues
Configure appropriate timeouts for HTTP clients
Cache Stampede
Use distributed locks for cache population
返回排行榜