WEBINAR: On-demand webcast
How to Boost Database Development Productivity on Linux, Docker, and Kubernetes with Microsoft SQL Server 2017 REGISTER >
By Krzysztof Cwalina and Brad Adams
From the CLR perspective, there are only two categories of types- reference types and value types-but for the purpose of framework design discussion we divide types into more logical groups, each with its own specific design rules. Figure 1 shows these logical groups.
Classes are the general case of reference types. They make up the bulk of types in the majority of frameworks. Classes owe their popularity to the rich set of object-oriented features they support and to their general applicability. Base classes and abstract classes are special logical groups related to extensibility.
Interfaces are types that can be implemented both by reference types and value types. This allows them to serve as roots of polymorphic hierarchies of reference types and value types. In addition, interfaces can be used to simulate multiple inheritance, which is not natively supported by the CLR.
Structs are the general case of value types and should be reserved for small, simple types, similar to language primitives.
Enums are a special case of value types used to define short sets of values, such as days of the week, console colors, and so on.
Static classes are types intended as containers for static members. They are commonly used to provide shortcuts to other operations.
Delegates, exceptions, attributes, arrays, and collections are all special cases of reference types intended for specific uses, and guidelines for their design and usage are discussed elsewhere in our book, Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries.
Figure 1: The logical grouping of types
DO ensure that each type is a well-defined set of related members, not just a random collection of unrelated functionality.
It is important that a type can be described in one simple sentence. A good definition should also rule out functionality that is only tangentially related.
BRAD ABRAMS If you have ever managed a team of people you know that they don't do well without a crisp set of responsibilities. Well, types work the same way. I have noticed that types without a firm and focused scope tend to be magnets for more random functionality, which, over time, make a small problem a lot worse. It becomes more difficult to justify why the next member with even more random functionality does not belong in the type. As the focus of the members in a type blurs, the developer's ability to predict where to find a given functionality is impaired, and therefore so is productivity.
RICO MARIANI Good types are like good diagrams: What has been omitted is as important to clarity and usability as what has been included. Every additional member you add to a type starts at a net negative value and only by proven usefulness does it go from there to positive. If you add too much in an attempt to make the type more useful to some, you are just as likely to make the type useless to everyone.
JEFFREY RICHTER When I was learning OOP back in the early 1980s, I was taught a mantra that I still honor today: If things get too complicated, make more types. Sometimes, I find that I am thinking really hard trying to define a good set of methods for a type. When I start to feel that I'm spending too much time on this or when things just don't seem to fit together well, I remember my mantra and I define more, smaller types where each type has well-defined functionality. This has worked extremely well for me over the years. On the flip side, sometimes types do end up being dumping grounds for various loosely related functions. The .NET Framework offers several types like this, such as Marshal, GC, Console, Math, and Application. You will note that all members of these types are static and so it is not possible to create any instances of these types. Programmers seem to be OK with this. Fortunately, these types' methods are separated a bit by types. It would be awful if all these methods were defined in just one type!
Types and Namespaces
Before designing a large framework you should decide how to factor your functionality into a set of functional areas represented by namespaces. This kind of top-down architectural design is important to ensure a coherent set of namespaces containing types that are well integrated, don't collide, and are not repetitive. Of course the namespace design process is iterative and it should be expected that the design will have to be tweaked as types are added to the namespaces over the course of several releases. This leads to the following guidelines.
DO use namespaces to organize types into a hierarchy of related feature areas.
The hierarchy should be optimized for developers browsing the framework for desired APIs.
KRZYSZTOF CWALINA This is an important guideline. Contrary to popular belief, the main purpose of namespaces is not to help in resolving naming conflicts between types with the same name. As the guideline states, the main purpose of namespaces is to organize types in a hierarchy that is coherent, easy to navigate, and easy to understand.
I consider type name conflicts in a single framework to indicate sloppy design. Types with identical names should either be merged to allow for better integration between parts of the library or should be renamed to improve code readability and searchability.
AVOID very deep namespace hierarchies. Such hierarchies are difficult to browse because the user has to backtrack often.
AVOID having too many namespaces.
Users of a framework should not have to import many namespaces in most common scenarios. Types that are used together in common scenarios should reside in a single namespace if at all possible.
JEFFREY RICHTER As an example of a problem, the runtime serializer types are defined under the System.Runtime.Serialization namespace and its subnamespaces. However, the Serializable and NonSerialized attributes are incorrectly defined in the System namespace. Because these types are not in the same namespace, developers don't realize that they are closely related. In fact, I have run into many developers who apply the Serializable attribute to a class that they are serializing with the System.Xml.Serialization's XmlSerializer type. However, the XmlSerializer completely ignores the Serializable attribute; applying the attribute gives no value and just bloats your assembly's metadata.
AVOID having types designed for advanced scenarios in the same namespace as types intended for common programming tasks.
This makes it easier to understand the basics of the framework and to use the framework in the common scenarios.
BRAD ABRAMS One of the best features of Visual Studio is Intellisense, which provides a drop-down for your likely next type or member usage. The benefit of this feature is inversely proportional to the number of options. That is, if there are too many items in the list it takes longer to find the one you are looking for. Following this guideline to split out advanced functionality into a separate namespace enables developers to see the smallest number of types possible in the common case.
BRIAN PEPIN One thing we've learned is that most programmers live or die by Intellisense. If something isn't listed in the drop-down, most programmers won't believe it exists. But, as Brad says above, too much of a good thing can be bad and having too much stuff in the drop-down list dilutes its value. If you have functionality that should be in the same namespace, but you don't think it needs to be shown all the time to users, you can use the EditorBrowsable attribute. Put this attribute on a class or member and you can instruct Intellisense to only show the class or member for advanced scenarios.
RICO MARIANI Don't go crazy adding members for every exotic thing someone might want to do with your type. You'll make fatter, uglier assemblies that are hard to grasp. Provide good primitives with understandable limitations. A great example of this is the urge people get to duplicate functionality that is already easy to use via Interop to native. Interop is there for a reason-it's not an unwanted stepchild. When wrapping anything, be sure you are adding plenty of value. Otherwise, the value added by being smaller would have made your assembly more helpful to more people.
JEFFREY RICHTER I agree with this guideline but I'd like to further add that the more advanced classes should be in a namespace that is under the namespace that contains the simple types. For example, the simple types might be in System.Mail and the more advanced types should be in System.Mail.Advanced.
DO NOT define types without specifying their namespaces. This organizes related types in a hierarchy, and can help to resolve potential type name collisions. Please note that the fact that namespaces can help to resolve name collisions does not mean that such collisions should be introduced.
DO NOT define types without specifying their namespaces.
This organizes related types in a hierarchy, and can help to resolve potential type name collisions. Please note that the fact that namespaces
BRAD ABRAMS It is important to realize that namespaces cannot actually prevent naming collisions but they can significantly reduce them. I could define a class called MyNamespace.MyType in an assembly called MyAssembly, and define a class with precisely the same name in another assembly. I could then build an application that uses both of these assemblies and types. The CLR would not get confused because the type identity in the CLR is based on strong name (which includes fully qualified assembly name) rather than just the namespace and type name. This can be seen by looking at the C# and ILASM of code creating an instance of MyType.
C#: new MyType(); IL: IL_0000: newobj instance void [MyAssembly]MyNamespace.MyType::.ctor()
Notice that the C# compiler adds a reference to the assembly that defines the type, of the form [MyAssembly], so the runtime always has a disambiguated, fully qualified name to work with.
JEFFREY RICHTER Although what Brad says is true, the C# compiler doesn't let you specify in source code which assembly to pull a type out of, so if you have code that wants to use a type called MyNamespace.MyType that exists in two or more assemblies, there is no easy way to do this in C# source code. Prior to C# 2.0, distinguishing between the two types was impossible. However, with C# 2.0, it is now possible using the new extern aliases and namespace qualifier features.
RICO MARIANI Namespaces are a language thing. The CLR doesn't know anything about them really. As far as the CLR is concerned the name of the class really is something like MyNameSpace.MyOtherNameSpace. MyAmazingType. The compilers give you syntax (e.g., "using") so that you don't have to type those long class names all the time. So the CLR is never confused about class names because everything is always fully qualified.
Standard Subnamespace Names
Types that are rarely used should be placed in subnamespaces to avoid cluttering the main namespaces. We have identified several groups of types that should be separated from their main namespaces.
The .Design Subnamespace
Design-time-only types should reside in a subnamespace named .Design. For example, System.Windows.Forms.Design contains Designers and related classes used to do design of applications based on System. Windows.Forms.
System.Windows.Forms.Design System.Messaging.Design System.Diagnostics.Design
DO use a namespace with the .Design suffix to contain types that provide design-time functionality for a base namespace.
The .Permissions SubnamespacePermission types should reside in a subnamespace named .Permissions.
DO use a namespace with the .Permissions suffix to contain types that provide custom permissions for a base namespace.
KRZYSZTOF CWALINA In the initial design of the .NET Framework namespaces, all types related to a given feature area were in the same namespace. Prior to the first release, we moved design-related types to subnamespaces with the .Design suffix. Unfortunately, we did not have time to do it for the Permission types. This is a problem in several parts of the Framework. For example, a large portion of the types in the System.Diagnostics namespace are types needed for the security infrastructure and very rarely used by the end users of the API.