Skip to main content

How to Write a Custom Validation Attribute in .NET C#

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

[Required] and [Range] cover a lot, but not everything. A price in a decimal(12,2) column must be 0 or more, must fit, and must have at most two decimal places, or MySQL rounds it without an error. A custom attribute puts that rule in one class, about ten lines long, that you can use anywhere.

Prerequisites​

Step 1: Inherit ValidationAttribute​

Create a class ending in Attribute, and say where it can go:

src/Dewiride.Inventory.Api/Common/MoneyAttribute.cs (excerpt)
using System.ComponentModel.DataAnnotations;

namespace Dewiride.Inventory.Api.Common;

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
public sealed class MoneyAttribute : ValidationAttribute

AttributeUsage lists Parameter for records and Property for classes, since a record's attribute goes on its constructor parameters.

Step 2: Set the Error Message​

Pass the default message to the base constructor. {0} becomes the field's name:

src/Dewiride.Inventory.Api/Common/MoneyAttribute.cs (excerpt)
public MoneyAttribute()
: base("The field {0} must be an amount from 0 to 9999999999.99, with at most 2 decimal places.")
{
}

Whoever uses the attribute can still replace it with ErrorMessage = "…".

Step 3: Write the Rule​

Override IsValid, which gets the value and answers true or false:

src/Dewiride.Inventory.Api/Common/MoneyAttribute.cs (excerpt)
public override bool IsValid(object? value) =>
value is null
|| (value is decimal amount
&& amount is >= 0 and <= Maximum
&& decimal.Round(amount, 2) == amount);

decimal.Round(amount, 2) == amount is true only when rounding changes nothing, which means there are at most two decimal places.

value is null counts as valid on purpose, and that's the convention for these attributes: "missing" is a separate question, answered by [Required]. It lets the same attribute work on an optional field.

The Complete File​

src/Dewiride.Inventory.Api/Common/MoneyAttribute.cs
using System.ComponentModel.DataAnnotations;

namespace Dewiride.Inventory.Api.Common;

/// <summary>
/// An amount of money that fits a decimal(12,2) column exactly: not negative, not too large and
/// with at most two decimal places. MySQL would round a third decimal place without any error.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
public sealed class MoneyAttribute : ValidationAttribute
{
private const decimal Maximum = 9_999_999_999.99m;

public MoneyAttribute()
: base("The field {0} must be an amount from 0 to 9999999999.99, with at most 2 decimal places.")
{
}

public override bool IsValid(object? value) =>
value is null
|| (value is decimal amount
&& amount is >= 0 and <= Maximum
&& decimal.Round(amount, 2) == amount);
}

The MoneyAttribute class in a macOS-style code window, showing the base constructor with its message and the IsValid override.

Step 4: Use It​

Leave off Attribute in the name, like any other attribute:

src/Dewiride.Inventory.Api/Features/Products/ProductContracts.cs (excerpt)
public sealed record CreateProductRequest(
[Required, StringLength(32)] string Sku,
[Required, StringLength(200)] string Name,
string? Description,
[Range(1, int.MaxValue)] int CategoryId,
[Required, Money] decimal? UnitPrice,
[Range(0, int.MaxValue)] int ReorderLevel);

Step 5: See It Work​

A negative price:

Response
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"UnitPrice": [
"The field UnitPrice must be an amount from 0 to 9999999999.99, with at most 2 decimal places."
]
},
"traceId": "00-fcc16368003f2b41ba100a0858f8a157-4ac9bf5b24d766fb-00"
}

Three decimal places, 9.999:

Response
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"UnitPrice": [
"The field UnitPrice must be an amount from 0 to 9999999999.99, with at most 2 decimal places."
]
},
"traceId": "00-640439fe84b3552b4c05ea5d300efa55-ac3bbd0c956f520f-00"
}

No price at all. That's [Required]'s message, because Money lets null through:

Response
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"UnitPrice": [
"The UnitPrice field is required."
]
},
"traceId": "00-3ef294d4f4809b6060ef90706edb6747-0c75ca43a6d85633-00"
}

Common Mistakes​

MistakeWhat happensDo this instead
Using it on a decimal that isn't nullableA missing price becomes 0 and passesUse decimal? with [Required]
Rejecting null in IsValidOptional fields can't use itLeave null to [Required]
Forgetting AddValidationIsValid is never called in a minimal APIRegister it
Checking the database in IsValidIt's a synchronous method with no good way to do thatCheck in the handler
A record with [AsParameters], with the attribute on the parameter/openapi/v1.json failsWrite [property: Money]

FAQ​

Why not [Range] with a decimal?​

[Range] checks the size, not the number of decimal places, so 9.999 would pass. That's the part that needed a rule of its own.

Can a rule compare two fields?​

Not with this method, which sees one value. The minimal API validation section names the other option: "Implementing the IValidatableObject interface for complex validation logic."

Conclusion​

Inherit ValidationAttribute, pass a message with {0} to the base constructor, override IsValid, and treat null as valid so [Required] stays separate. One small class keeps the rule in one place, and gives clients a clear message instead of a silently rounded number.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.