When designing software, one of the most critical activities is defining dependencies between components, that is, defining who is allowed to call whom. In C#, this concept is referred to as accessibility. For optimal design, it's advisable to always grant the least necessary accessibility. This principle, similar to the "need to know" concept in intelligence services, benefits software architecture by minimizing unintended coupling between components and facilitating changes to individual components in the future.
In C#, accessibility is defined across two boundaries: assemblies and types. As you surely know, private
members are only accessible from the current type, protected
members are accessible from the current and any child type, public
members are universally visible, and internal
members are only accessible from the current assembly unless an InternalsVisibleTo
extends the accessibility of internal members to other assemblies.
However, large projects often require finer control over accessibility than what C# can provide out of the box.
For instance, you might want to enforce rules such as:
- Requiring a specific method or constructor to be called from unit tests only, based on the caller namespace.
- Forbidding a type from being accessed from outside its home namespace.
- Requiring a whole namespace only to be used by a friend namespace.
- Forbidding internal members of a namespace from being accessed outside of their home namespace.
The traditional approach to enforcing such rules is to use code comments and then rely on manual code reviews to enforce the desired design intent. However, this approach has two significant weaknesses: it is prone to human errors and suffers from a lengthy feedback loop. Another approach is to split the codebase into a more fine-grained structure of projects, but this increases the build and deployment complexity and negatively affects the application start-up time.
Thanks to Metalama, you can easily fine-tune the intended accessibility of your namespaces, types, or members using custom attributes or a compile-time API.
Validating usage with custom attributes
When you want to fine-tune the accessibility of hand-picked types or members, using custom attributes is the easiest solution.
Follow these steps:
Add the
Metalama.Extensions.Architecture
package to your project.Apply one of the following custom attributes to the type or member for which you want to limit the accessibility.
Attribute Description CanOnlyBeUsedFromAttribute Reports a warning when the target declaration is accessed from outside of the given scope. InternalsCanOnlyBeUsedFromAttribute Reports a warning when any internal
member of the type is accessed from outside the given scope.CannotBeUsedFromAttribute Reports a warning when the target declaration is accessed from the given scope. InternalsCannotBeUsedFromAttribute Reports a warning when any internal
member of the type is accessed from the given scope.Set one or many of the following properties of the custom attribute, which control the scope, that is, which declarations can or cannot access the target declaration:
Property Description CurrentNamespace Includes the current namespace in the scope. Types Includes a list of types in the scope. Namespaces Includes a list of namespaces in the scope by identifying them with a string. One asterisk ( *
) matches any namespace component but not the dot (.
). A double asterisk (**
) matches any substring including the dot (.
).NamespaceOfTypes Includes a list of the namespaces in the scope by identifying them with arbitrary types of these namespaces. Optionally, set the Description property. The value of this property will be appended to the standard error message.
Example: Test-only constructor
In the following example, the class Foo
has two constructors, and one of them should only be used in tests. Tests are identified as any code in a namespace ending with the .Tests
suffix. We define the Description to improve the error message. You can also set the ReferenceKinds to limit the kinds of references that are validated.
1using Metalama.Extensions.Architecture.Aspects;
2
3namespace Doc.Architecture.Type_ForTestOnly
4{
5 public class Foo
6 {
7 private bool _isTest;
8
9 public Foo() { }
10
11 [CanOnlyBeUsedFrom(
12 Namespaces = new[] { "**.Tests" },
13 Description = "Use this constructor in tests only." )]
14 public Foo( bool isTest )
15 {
16 this._isTest = isTest;
17 }
18 }
19
20 internal class ForbiddenClass
21 {
22 // This call is forbidden because it is not in a **.Tests namespace.
Warning LAMA0905: The 'Foo.Foo(bool)' constructor cannot be referenced by the 'ForbiddenClass' type. Use this constructor in tests only.
23 private Foo _c = new( true );
24 }
25
26 namespace Tests
27 {
28 internal class TestClass
29 {
30 // This call is allowed.
31 private Foo _c = new( true );
32 }
33 }
34}
Example: Type internals reserved for the current namespace
In the following example, the class Foo
uses the InternalsCanOnlyBeUsedFromAttribute constraint to verify that internal members are only accessed from the same namespace. A warning is reported when an internal method of Foo
is accessed from a different method.
1using Doc.Architecture.Type_CurrentNamespace.A;
2using Metalama.Extensions.Architecture.Aspects;
3
4namespace Doc.Architecture.Type_CurrentNamespace
5{
6 namespace A
7 {
8 [InternalsCanOnlyBeUsedFrom( CurrentNamespace = true )]
9 public class Foo
10 {
11 public void PublicMethod() { }
12
13 internal void InternalMethod() { }
14 }
15
16 public class AllowedClass
17 {
18 public void M()
19 {
20 var foo = new Foo();
21
22 // Allowed because public.
23 foo.PublicMethod();
24
25 // Allowed because same namespace.
26 foo.InternalMethod();
27 }
28 }
29 }
30
31 namespace B
32 {
33 public class ForbiddenClass
34 {
35 public void M()
36 {
37 var foo = new Foo();
38
39 // Allowed because public.
40 foo.PublicMethod();
41
42 // Forbidden because different namespace.
Warning LAMA0905: The 'Foo.InternalMethod()' method cannot be referenced by the 'ForbiddenClass' type.
43 foo.InternalMethod();
44 }
45 }
46 }
47}
Validating usage programmatically
Custom attributes are adequate when the types or members to validate have to be hand-picked. However, when these types or members can be selected by a rule, it is more efficient to do it programmatically, with compile-time code and fabrics.
Follow these steps:
Add the
Metalama.Extensions.Architecture
package to your project.Create or reuse a fabric type as described in Fabrics:
- To concentrate the whole validation logic for the whole project into a single location, create a ProjectFabric.
- To share the validation logic among several projects, see Adding aspects to multiple projects.
- To split the logic on a per-namespace basis, create one NamespaceFabric in each namespace that you want to validate.
- To validate specific types, you can use custom attributes or add a nested TypeFabric to this type.
Import the Metalama.Extensions.Architecture.Fabrics and Metalama.Extensions.Architecture.Predicates namespaces to benefit from extension methods.
Edit the AmendProject, AmendNamespace or AmendType of this method.
Call one of the following methods:
Attribute Description CanOnlyBeUsedFrom Reports a warning when the target declaration is accessed from outside the given scope. InternalsCanOnlyBeUsedFrom Reports a warning when any internal
member of the type is accessed from outside of the given scope.CannotBeUsedFrom Reports a warning when the target declaration is accessed from the given scope. InternalsCannotBeUsedFrom Reports a warning when any internal
member of the type is accessed from the given scope.Pass a delegate like
r => r.ScopeMethod()
whereScopeMethod
is one of the following methods:Method Description CurrentNamespace Includes the current namespace in the scope. NamespaceOf Includes the parent namespace of a given type in the scope Type Includes a given type in the scope. Namespace Includes a given namespace in the scope. One asterisk ( *
) matches any namespace component but not the dot (.
). A double asterisk (**
) matches any substring including the dot (.
).For instance:
amender.CanOnlyBeUsedFrom( r => r.CurrentNamespace() );
You can create complex conditions thanks to the And, Or and Not methods.
Optionally, you can pass a value for the
description
parameter. This text will be appended to the warning message. You can also supply a ReferenceKinds to limit the kinds of references that are validated.
Example: Namespace internals reserved for the current namespace
In the following example, we use a namespace fabric to restrict the accessibility of internal members to this namespace. A warning is reported when this rule is violated, like in the ForbiddenInheritor
class.
1using Doc.Architecture.Fabric_InternalNamespace.VerifiedNamespace;
2using Metalama.Extensions.Architecture;
3using Metalama.Extensions.Architecture.Predicates;
4using Metalama.Framework.Fabrics;
5
6namespace Doc.Architecture.Fabric_InternalNamespace
7{
8 namespace VerifiedNamespace
9 {
10 internal class Fabric : NamespaceFabric
11 {
12 public override void AmendNamespace( INamespaceAmender amender )
13 {
14 amender.CanOnlyBeUsedFrom( r => r.CurrentNamespace() );
15 }
16 }
17
18 internal class Foo { }
19
20 internal class AllowedInheritor : Foo { }
21 }
22
23 namespace OtherNamespace
24 {
Warning LAMA0905: The 'Doc.Architecture.Fabric_InternalNamespace.VerifiedNamespace' namespace cannot be referenced by the 'Doc.Architecture.Fabric_InternalNamespace.OtherNamespace' namespace.
25 internal class ForbiddenInheritor : Foo { }
26 }
27}
Example: Forbidding the use of floating-point arithmetic from the Invoicing namespace
Using floating-point arithmetic in operations involving currencies is a common pitfall. Instead, decimal
numbers should be used. In the following example, we use a project fabric to validate all references to the float
and double
types. We report a diagnostic when they are used from the **.Invoicing
namespaces.
1using Metalama.Extensions.Architecture;
2using Metalama.Extensions.Architecture.Predicates;
3using Metalama.Framework.Aspects;
4using Metalama.Framework.Fabrics;
5
6namespace Doc.Architecture.Fabric_ForbidFloat
7{
8 internal class Fabric : ProjectFabric
9 {
10 public override void AmendProject( IProjectAmender amender )
11 {
12 amender
13 .SelectReflectionTypes( typeof(float), typeof(double) )
14 .CannotBeUsedFrom(
15 r => r.Namespace( "**.Invoicing" ),
16 "Use decimal numbers instead." );
17 }
18 }
19
20 namespace Invoicing
21 {
22 internal class Invoice
23 {
Warning LAMA0905: The 'double' type cannot be referenced by the 'Doc.Architecture.Fabric_ForbidFloat.Invoicing' namespace. Use decimal numbers instead.
24 public double Amount { get; set; }
25 }
26 }
27}