diff --git a/README.md b/README.md
index 9d50f72b3..a4aa5d82b 100755
--- a/README.md
+++ b/README.md
@@ -202,6 +202,7 @@ If you are already using other analyzers, you can check [which rules are duplica
|[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|ℹ️|✔️|✔️|
|[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|ℹ️|❌|❌|
|[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|ℹ️|❌|✔️|
+|[MA0188](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0188.md)|Design|Use System.TimeProvider instead of a custom time abstraction|ℹ️|✔️|❌|
diff --git a/docs/README.md b/docs/README.md
index 832ad560f..d99d63b14 100755
--- a/docs/README.md
+++ b/docs/README.md
@@ -186,6 +186,7 @@
|[MA0185](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0185.md)|Performance|Simplify string.Create when all parameters are culture invariant|ℹ️|✔️|✔️|
|[MA0186](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0186.md)|Design|Equals method should use \[NotNullWhen(true)\] on the parameter|ℹ️|❌|❌|
|[MA0187](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0187.md)|Design|Use constructor injection instead of \[Inject\] attribute|ℹ️|❌|✔️|
+|[MA0188](https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0188.md)|Design|Use System.TimeProvider instead of a custom time abstraction|ℹ️|✔️|❌|
|Id|Suppressed rule|Justification|
|--|---------------|-------------|
@@ -759,6 +760,9 @@ dotnet_diagnostic.MA0186.severity = none
# MA0187: Use constructor injection instead of [Inject] attribute
dotnet_diagnostic.MA0187.severity = none
+
+# MA0188: Use System.TimeProvider instead of a custom time abstraction
+dotnet_diagnostic.MA0188.severity = suggestion
```
# .editorconfig - all rules disabled
@@ -1318,4 +1322,7 @@ dotnet_diagnostic.MA0186.severity = none
# MA0187: Use constructor injection instead of [Inject] attribute
dotnet_diagnostic.MA0187.severity = none
+
+# MA0188: Use System.TimeProvider instead of a custom time abstraction
+dotnet_diagnostic.MA0188.severity = none
```
diff --git a/docs/Rules/MA0188.md b/docs/Rules/MA0188.md
new file mode 100644
index 000000000..4f5f9b6fa
--- /dev/null
+++ b/docs/Rules/MA0188.md
@@ -0,0 +1,39 @@
+# MA0188 - Use System.TimeProvider instead of a custom time abstraction
+
+Source: [UseTimeProviderInsteadOfInterfaceAnalyzer.cs](https://github.com/meziantou/Meziantou.Analyzer/blob/main/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs)
+
+
+`System.TimeProvider` (available since .NET 8) provides a standard abstraction for time-related operations. Defining a custom interface for this purpose is unnecessary when `System.TimeProvider` covers the same use case. For older target frameworks, the [`Microsoft.Bcl.TimeProvider`](https://www.nuget.org/packages/Microsoft.Bcl.TimeProvider) NuGet package provides a backport.
+
+This rule reports interfaces whose members consist entirely of time-related properties or methods (`Now`, `UtcNow`, `GetNow()`, `GetUtcNow()`) returning `DateTime` or `DateTimeOffset`.
+
+## Non-compliant code
+
+```csharp
+interface ITimeProvider
+{
+ System.DateTime UtcNow { get; }
+}
+```
+
+```csharp
+interface ITimeService
+{
+ System.DateTimeOffset GetUtcNow();
+}
+```
+
+## Compliant code
+
+Use `System.TimeProvider` directly instead of defining a custom interface:
+
+```csharp
+class Sample
+{
+ void Usage(System.TimeProvider timeProvider)
+ {
+ var now = timeProvider.GetUtcNow();
+ }
+}
+```
+
diff --git a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig
index cc2a14297..5ebd91120 100644
--- a/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig
+++ b/src/Meziantou.Analyzer.Pack/configuration/default.editorconfig
@@ -556,3 +556,6 @@ dotnet_diagnostic.MA0186.severity = none
# MA0187: Use constructor injection instead of [Inject] attribute
dotnet_diagnostic.MA0187.severity = none
+
+# MA0188: Use System.TimeProvider instead of a custom time abstraction
+dotnet_diagnostic.MA0188.severity = suggestion
diff --git a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig
index cca6726d6..c469c38a6 100644
--- a/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig
+++ b/src/Meziantou.Analyzer.Pack/configuration/none.editorconfig
@@ -556,3 +556,6 @@ dotnet_diagnostic.MA0186.severity = none
# MA0187: Use constructor injection instead of [Inject] attribute
dotnet_diagnostic.MA0187.severity = none
+
+# MA0188: Use System.TimeProvider instead of a custom time abstraction
+dotnet_diagnostic.MA0188.severity = none
diff --git a/src/Meziantou.Analyzer/RuleIdentifiers.cs b/src/Meziantou.Analyzer/RuleIdentifiers.cs
index 95da6fa0a..d075dd5fe 100755
--- a/src/Meziantou.Analyzer/RuleIdentifiers.cs
+++ b/src/Meziantou.Analyzer/RuleIdentifiers.cs
@@ -187,6 +187,7 @@ internal static class RuleIdentifiers
public const string SimplifyStringCreateWhenAllParametersAreCultureInvariant = "MA0185";
public const string MissingNotNullWhenAttributeOnEquals = "MA0186";
public const string BlazorPropertyInjectionShouldUseConstructorInjection = "MA0187";
+ public const string UseTimeProviderInsteadOfInterface = "MA0188";
public static string GetHelpUri(string identifier)
{
diff --git a/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs b/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs
new file mode 100644
index 000000000..bbbd45573
--- /dev/null
+++ b/src/Meziantou.Analyzer/Rules/UseTimeProviderInsteadOfInterfaceAnalyzer.cs
@@ -0,0 +1,100 @@
+using System.Collections.Immutable;
+using Meziantou.Analyzer.Internals;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Meziantou.Analyzer.Rules;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class UseTimeProviderInsteadOfInterfaceAnalyzer : DiagnosticAnalyzer
+{
+ private static readonly DiagnosticDescriptor Rule = new(
+ RuleIdentifiers.UseTimeProviderInsteadOfInterface,
+ title: "Use System.TimeProvider instead of a custom time abstraction",
+ messageFormat: "Use System.TimeProvider instead of defining a custom time abstraction",
+ RuleCategories.Design,
+ DiagnosticSeverity.Info,
+ isEnabledByDefault: true,
+ description: "",
+ helpLinkUri: RuleIdentifiers.GetHelpUri(RuleIdentifiers.UseTimeProviderInsteadOfInterface));
+
+ public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule);
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(ctx =>
+ {
+ var dateTimeSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.DateTime");
+ var dateTimeOffsetSymbol = ctx.Compilation.GetBestTypeByMetadataName("System.DateTimeOffset");
+
+ if (dateTimeSymbol is null && dateTimeOffsetSymbol is null)
+ return;
+
+ ctx.RegisterSymbolAction(ctx => AnalyzeNamedType(ctx, dateTimeSymbol, dateTimeOffsetSymbol), SymbolKind.NamedType);
+ });
+ }
+
+ private static void AnalyzeNamedType(SymbolAnalysisContext context, INamedTypeSymbol? dateTimeSymbol, INamedTypeSymbol? dateTimeOffsetSymbol)
+ {
+ var type = (INamedTypeSymbol)context.Symbol;
+ if (type.TypeKind is not TypeKind.Interface)
+ return;
+
+ // Must have at least one member
+ var members = type.GetMembers();
+ if (members.IsEmpty)
+ return;
+
+ // All members must be time-provider-like (skip property accessor methods as they're covered by property symbols)
+ foreach (var member in members)
+ {
+ // Skip property accessor methods - they are represented by the IPropertySymbol
+ if (member is IMethodSymbol { MethodKind: MethodKind.PropertyGet or MethodKind.PropertySet })
+ continue;
+
+ if (!IsTimeProviderLikeMember(member, dateTimeSymbol, dateTimeOffsetSymbol))
+ return;
+ }
+
+ context.ReportDiagnostic(Rule, type);
+ }
+
+ private static bool IsTimeProviderLikeMember(ISymbol member, INamedTypeSymbol? dateTimeSymbol, INamedTypeSymbol? dateTimeOffsetSymbol)
+ {
+ if (member.IsStatic)
+ return false;
+
+ if (member is IPropertySymbol property)
+ {
+ if (property.Name is not ("Now" or "UtcNow" or "GetNow" or "GetUtcNow" or "CurrentTime"))
+ return false;
+
+ return IsDateTimeOrDateTimeOffset(property.Type, dateTimeSymbol, dateTimeOffsetSymbol);
+ }
+
+ if (member is IMethodSymbol method)
+ {
+ // Exclude property accessor methods
+ if (method.MethodKind is not MethodKind.Ordinary)
+ return false;
+
+ if (method.Name is not ("Now" or "UtcNow" or "GetNow" or "GetUtcNow" or "CurrentTime"))
+ return false;
+
+ if (!method.Parameters.IsEmpty)
+ return false;
+
+ return IsDateTimeOrDateTimeOffset(method.ReturnType, dateTimeSymbol, dateTimeOffsetSymbol);
+ }
+
+ return false;
+ }
+
+ private static bool IsDateTimeOrDateTimeOffset(ITypeSymbol type, INamedTypeSymbol? dateTimeSymbol, INamedTypeSymbol? dateTimeOffsetSymbol)
+ {
+ return type.IsEqualTo(dateTimeSymbol) || type.IsEqualTo(dateTimeOffsetSymbol);
+ }
+}
diff --git a/tests/Meziantou.Analyzer.Test/Rules/UseTimeProviderInsteadOfInterfaceAnalyzerTests.cs b/tests/Meziantou.Analyzer.Test/Rules/UseTimeProviderInsteadOfInterfaceAnalyzerTests.cs
new file mode 100644
index 000000000..59135d5a1
--- /dev/null
+++ b/tests/Meziantou.Analyzer.Test/Rules/UseTimeProviderInsteadOfInterfaceAnalyzerTests.cs
@@ -0,0 +1,197 @@
+using Meziantou.Analyzer.Rules;
+using TestHelper;
+
+namespace Meziantou.Analyzer.Test.Rules;
+
+public sealed class UseTimeProviderInsteadOfInterfaceAnalyzerTests
+{
+ private static ProjectBuilder CreateProjectBuilder()
+ {
+ return new ProjectBuilder()
+ .WithAnalyzer();
+ }
+
+ [Fact]
+ public async Task Interface_UtcNowProperty_DateTime_ReportsDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface [|ITimeProvider|]
+ {
+ System.DateTime UtcNow { get; }
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_NowProperty_DateTimeOffset_ReportsDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface [|ITimeProvider|]
+ {
+ System.DateTimeOffset Now { get; }
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_BothNowAndUtcNow_ReportsDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface [|ITimeProvider|]
+ {
+ System.DateTime Now { get; }
+ System.DateTime UtcNow { get; }
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_GetNowMethod_ReportsDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface [|ITimeProvider|]
+ {
+ System.DateTime GetNow();
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_GetUtcNowMethod_ReportsDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface [|ITimeProvider|]
+ {
+ System.DateTimeOffset GetUtcNow();
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_MixedProperties_ReportsDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface [|ITimeService|]
+ {
+ System.DateTime GetNow();
+ System.DateTimeOffset GetUtcNow();
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_EmptyInterface_NoDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface IEmpty
+ {
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_OtherMembers_NoDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface ITimeProvider
+ {
+ System.DateTime UtcNow { get; }
+ void DoSomething();
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_WrongReturnType_NoDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface ITimeProvider
+ {
+ string UtcNow { get; }
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_MethodWithParameters_NoDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface ITimeProvider
+ {
+ System.DateTime GetNow(string timeZone);
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_CurrentTimeProperty_ReportsDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface [|ITimeProvider|]
+ {
+ System.DateTime CurrentTime { get; }
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_WrongName_NoDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface ITimeProvider
+ {
+ System.DateTime GetCurrentTime();
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Class_NotAnInterface_NoDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ class TimeProvider
+ {
+ public System.DateTime UtcNow { get; }
+ }
+ """)
+ .ValidateAsync();
+ }
+
+ [Fact]
+ public async Task Interface_StaticMember_NoDiagnostic()
+ {
+ await CreateProjectBuilder()
+ .WithSourceCode("""
+ interface ITimeProvider
+ {
+ static System.DateTime UtcNow { get; }
+ }
+ """)
+ .ValidateAsync();
+ }
+}