This article may contain URLs that were valid when originally published, but now link to sites or pages that no longer exist. To maintain the flow of the article, we've left these URLs in the text, but disabled the links.

MSDN Magazine

.NET Framework: Building, Packaging, Deploying, and Administering Applications and Types

Jeffrey Richter

This article assumes you�re familiar with .NET and COM+

Level of Difficulty     1   2   3 

Download the code for this article: BuildApps.exe(33KB) 

Browse the code for this article at Code Center: BuildApps Demo 

SUMMARY Types that are built for the Common Language Runtime can be shared among applications in the Microsoft .NET Framework no matter which of the .NET languages they were built in, an obvious benefit to developers.
      This article describes the building, packaging, and deploying of applications and types for the .NET Framework, including the role and significance of assemblies, private and otherwise. The way metadata and assemblies help to solve some historical problems like versioning conflicts and DLL Hell, and how they improve system stability are also discussed.

Today, applications are frequently made up of several object types that haven't all been created by a single entity. More likely an application will use types created by the company itself but can also take advantage of types created by Microsoft and possibly many other vendors as well. If these types are developed using any language that targets the Microsoft® .NET common language runtime (CLR), then all these types can work together seamlessly, even to the point where a type can use another type as its base class, regardless of what languages the types are developed in.
      In this article, I'll look at how these types are built and packaged into files for deployment. I'll also take a brief historical look at some of the problems that the .NET Framework is solving.

.NET Framework Deployment Goals

      Over the years, Windows® has sometimes been considered unstable and complicated. This reputation can be attributed to many different things. For instance, applications almost always use dynamic link libraries (DLLs) from Microsoft or other vendors. Since an application executes code from different vendors, the developer of any one piece of code cannot be 100 percent sure how it is going to be used by someone else. While this has the potential to cause all kinds of problems, in practice, problems do not typically arise from this kind of interaction because applications are tested and debugged before they are deployed.
      However, users frequently run into trouble when one company decides to update its code and ship new files. These new files are supposed to be backward-compatible with the previous files, but who knows for sure? When one vendor updates their code, it is usually impossible to retest and debug all the apps that are already shipping to ensure that the changes have had no undesirable effect.
      You have probably experienced the problem in which installing a new application somehow corrupts a previously installed application. This type of instability puts fear into the hearts and minds of typical computer users who have to carefully consider whether it's worth installing new software on their machines. Personally, I have decided not to give certain applications a try, for fear that some application on which I rely will be adversely affected.
      Furthermore, installing software on Windows can be complicated. When applications are installed, they often affect all parts of the system. For example, installing an application causes files to be copied to various directories, registry settings to be updated, and shortcuts to be created on your desktop, Start menu, and Quick Launch toolbar. The problem is that the application is not isolated as a single entity. You cannot easily back up the application since you must copy the application's files and also the relevant parts of the registry. In addition, you cannot easily move the application from one machine to another; you must run the installation program again so that all files and registry settings are set properly. Finally, you cannot easily uninstall or remove the application without having this nasty feeling that some part of the package is still lurking on your machine.
      The third reason for the reputation of Windows has to do with security. When applications are installed, they come with all kinds of files, many of them written by different companies. In addition, Web applications frequently send code over the wire in such a way that users don't realize code is being installed on their machine. Today, this code can perform any operationâ€"including deleting files or sending e-mail. Users are right to be terrified of installing new applications because of the potential damage they can cause. To make users comfortable, security must be built into the system so that users can explicitly allow or disallow code developed by various companies to access the system resources.

Building Types into a Module

      In this section, I'll examine how to turn your source file, containing various types, into a file that can be deployed. Let's start by examining the following simple application:

  public class App {
static public void Main(System.String[] args) {

      This simple application defines a type called App that has a single static, public method called Main. Inside Main is a reference to another type called System.Console. System.Console is a type implemented by Microsoft, and the Microsoft intermediate language (MSIL) code that implements this type's methods are in the MSCorLib.dll file. So, the application defines a type and also uses another company's type.
      To build this sample application, I put the previous code into a source code file, (let's call it App.cs), and then execute the following command line:

  csc.exe /out:App.exe /t:exe /r:MSCorLib.dll App.cs

This command line tells the C# compiler to emit an executable file called App.exe (/out:App.exe). The type of file produced is a Win32® console application (/t[arget]:exe).
      When the C# compiler processes the source file, it sees that the code references the System.Console type's WriteLine method. At this point, the compiler wants to ensure that this type exists somewhere, that it has a WriteLine method, and that the types of the arguments that WriteLine expects match up with what the program is supplying. To make the C# compiler happy, I must give it a set of assemblies (which I'll define shortly) that it can use to resolve references to external types. In the earlier command line, I've included the /r[eference]:MSCorLib.dll switch, telling the compiler to look for external types in the assembly identified by the MSCorLib.dll file.
      MSCorLib.dll is a special file in that it contains all the core types such as bytes, integers, characters, strings, and so on. In fact, these types are used so frequently that the C# compiler automatically references this assembly. In other words, the following command line is identical to the line shown earlier, with the /r switch omitted:

  csc.exe /out:App.exe /t:exe App.cs

      If, for some reason, you really don't want the C# compiler to reference the MSCorLib.dll assembly, you can use the /nostdlib switch. For example, the following command line will generate an error when compiling the App.cs file:

  csc.exe /out:App.exe /t:exe /nostdlib App.cs

      Now, let's take a closer look at the App.exe file produced by the C# compiler. What exactly is this file? Well, for starters, it's a standard portable executable (PE) file. This means that a machine running 32-bit or 64-bit Windows should be able to load this file and do something with it. Windows supports two types of applications: those with a console user interface (CUI) and those with graphical user interface (GUI). Since I specified the /t:exe switch, the C# compiler produced a CUI application; the /t:winexe switch makes the C# compiler produce a GUI application.
      Well, now you know what kind of PE file I've created. But what exactly is in the App.exe file? There are three main components of a managed PE file: The CLR header, the metadata, and the MSIL. The CLR header is a small block of information that is specific to modules that require the CLR (managed modules). The header includes the major and minor version number of the CLR that the module was built for, some flags, a MethodDef token (which I'll describe later) indicating the module's entry point method if this module is a CUI or GUI executable, and a strong name digital signature (I'll discuss later). Finally, the header contains the size and offsets of certain metadata tables contained in the module.
      The metadata is a block of binary data that consists of several tables. Figure 1 describes some of the more common tables that exist in a module's metadata block.
      The metadata tables in Figure 1 define things that are implemented within the module. Metadata tables also exist to indicate what things are being referenced from other assemblies. Figure 2 shows some of the more common reference metadata tables.
      There are many more tables than I have shown in Figure 1  and Figure 2 , but I wanted to give you a taste of the kind of information that is emitted by the compiler to produce the metadata information.
      There are various tools available that allow you to examine the metadata within a managed PE file. My personal favorite is ILDasm.exe, the MSIL disassembler. To see the metadata tables, execute the following command line:

  ILDasm /Adv App.exe

      This causes ILDasm.exe to run, loading the App.exe assembly. The /Adv switch tells ILDasm to make some advanced menu items available (which can be found on the View menu). To see the metadata in a nice, human-readable form, select the View.MetaInfo.Show! menu item. This causes information like that in Figure 3 to appear.
      Fortunately, ILDasm processes the metadata tables and combines information where appropriate so that you don't have to parse the raw table information yourself. For example, in Figure 3, you see that when ILDasm shows a TypeDef entry, the corresponding member definition information is shown with it before the first TypeRef entry is displayed.
      It is not important to fully understand everything you see here. The important thing to note is that App.exe contains a TypeDef whose name is App. This type identifies a public class that is derived from System.Object (a type referenced from another assembly). The App type also defines two methods: Main and .ctor (which is a constructor).
      Main is a static, public method whose code is MSIL (versus native CPU code, such as x86). Main has a void return type and takes a single argument, called args, which is an array of Strings. The constructor method (always shown with a name of .ctor) is public and its code is also MSIL. The constructor has a void return type and no arguments, but it has a this pointer which refers to the object's memory to be constructed when the method is called.
      I strongly encourage you to experiment with ILDasmâ€"it can show you a wealth of information. The more you understand about what you're seeing, the better you will understand the CLR.
      Just for fun, let's look at some statistics about the App.exe assembly. When you select ILDasm's View.Statistics menu item, the information in Figure 4 is displayed.
      You can see the size (in bytes) of the file and the size (in bytes and as a percentage) of the various parts that make up the file. For this very small App.cs application, the PE header and the metadata occupy the bulk of the file's size. In fact, the MSIL code occupies just 18 bytes. Of course, as an application grows, it will reuse most of its types and references to other types and assemblies, causing the metadata and header information to shrink considerably as compared to the overall size of the file.

Building Types into an Assembly

      The App.exe file discussed in the previous section is more than just a PE file with metadata; it is also an assembly. In the .NET platform, an assembly is the unit of reuse, versioning, security, and deployment. So, in order to package and deploy your types, they must be placed into modules that are part of an assembly. In many cases, an assembly will consist of a single file, as is the case with the App.exe example I've been discussing. But, an assembly may consist of multiple files: some PE files with metadata and some resource files like .gif or .jpg files. The choice is yours. When working with assemblies, it may help to think of an assembly as a logical EXE or DLL.
      I'm sure that many of you are wondering why Microsoft has introduced this new assembly concept. The reason is that an assembly allows you to decouple the logical and physical notions of reusable types. For example, an assembly may consist of several types. You could put the frequently used types in one file and the less frequently used types in another file. If your assembly is deployed by downloading it via the Internet, then the file with the infrequently used types may not ever have to be downloaded to the client if the client never accesses the types. For example, an ISV specializing in UI controls might choose to implement Active Accessibility types in a separate module (to satisfy Microsoft logo requirements). Only users that require the additional accessibility features would require that this module be downloaded; other users would not. To summarize, the assembly is the unit of reuse and versioning, while the file (or module) is the unit of download and distribution.
      From an assembly consumer's perspective (the external view), an assembly is a named and versioned collection of exported types and resources. From an assembly developer's perspective (the internal view), an assembly is a collection of one or more filesâ€"from a single PE file to a collection of PE files, resource files, HTML pages, GIFs, and so onâ€"that implement types and resources.
      To build an assembly, you must select one of your PE files to be the keeper of a manifest. Or, you can also create a separate PE file that contains nothing but a manifest. A manifest is a set of tables that describe the assembly's identity, culture, files, and publicly exported types, and all of the files that comprise the assembly. It also indicates other referenced assemblies on which the assembly is dependent. Selecting a PE file to hold an assembly's manifest means that the PE file's metadata includes some extra tables. Figure 5 shows the metadata tables that are part of a PE file's manifest metadata.
      The existence of a manifest provides a level of indirection between consumers of the assembly and the implementation details of the assembly and makes assemblies self-describing. Also, note that the file containing the manifest knows which files are part of the assembly, but the individual files themselves are not aware that they are part of an assembly.
      The C# compiler produces an assembly when you specify any of the following three command-line switches: /t[arget]:exe, /t[arget]:winexe, or /t[arget]:library. All of these switches cause the compiler to generate a single PE file that contains the manifest metadata tables. The resulting file is either a CUI executable, GUI executable, or DLL, respectively.
      In addition to these switches, the C# compiler also supports the /t[arget]:module switch. This switch tells the compiler to produce a PE file that does not contain the manifest metadata tables. The PE file produced is always a DLL PE file; this file must be added to an assembly before the types within it can be accessed.
      There are many ways to add a module to an assembly. If you are using the C# compiler to build a PE file with a manifest, then you can use the /addmodule switch. To understand how to build a multifile assembly, let's assume that you have two source code files: RUT.cs, which contains rarely used types, and FUT.cs, which contains frequently used types.
      Since the RUT contents are rarely used, let's compile them into their own module so that users of the assembly won't need this module if they never access these types:

  csc /out:RUT.mod /t:module RUT.cs

Note that the RUT.mod file is a standard DLL PE file. I've chosen to give it an extension of .mod to indicate that it is a module that must become part of an assembly in order for its types to be accessed.
      Now, I'll compile the frequently used types into their own module. Since these types are accessed more often, I'll make this module the keeper of the assembly's manifest. In fact, since this module will now represent the entire assembly, I'll change the name of the output file to JeffTypes.dll instead of calling it FUT.dll:

  csc /out:JeffTypes.dll /t:library /addmodule:RUT.mod FUT.cs

      The previous line tells the C# compiler to compile the FUT.cs file to produce the JeffTypes.dll file. Since /t:library is specified, a DLL PE file containing the manifest metadata tables are emitted into the JeffTypes.dll file. The /addmodule:RUT.mod switch also tells the compiler that RUT.mod is a file that should be considered part of the assembly. Specifically, the /addmodule switch tells the compiler to add the file to the FileDef table and to add RUT.mod's publicly exported types to the ExportedTypesDef table. Once the compiler has finished all of its processing, you have two files, as shown in Figure 6.

Figure 6 RUT Module and JeffTypes.dll
Figure 6 RUT Module and JeffTypes.dll

      The RUT.mod module contains the MSIL code generated by compiling RUT.cs. This module also contains metadata tables that describe the types, methods, fields, properties, events, and so on that are defined by RUT.cs. The metadata tables also describe the types, methods, and so on that are referenced by RUT.cs. The JeffTypes.dll is a separate file. Like RUT.mod, this file includes the MSIL code generated by compiling FUT.cs, and also includes similar definition and reference metadata tables. However, JeffTypes.dll contains the additional manifest metadata tables, making JeffTypes.dll an assembly. The additional manifest metadata tables describe all the files that make up the assembly (the JeffTypes.dll file itself and the RUT.mod file). The manifest metadata tables also include all the public types exported from JeffType.dll and RUT.mod.
      In reality, the manifest metadata tables do not include the types that are exported from the PE file that contains the manifest. This is an optimization whose purpose is to reduce the number of bytes required by the manifest information in the PE file.
      So, to say that the manifest metadata tables also include all the public types exported from JeffType.dll and RUT.mod is not 100 percent accurate. However, this statement does accurately reflect what the manifest is logically exposing.
      Once the JeffTypes.dll assembly is built, you can use ILDasm.exe to examine the metadata's manifest tables to verify that the assembly file does, in fact, have references to the RUT.mod file's types. To make this more concrete, the sample code provided with this article contains a project that constructs the JeffTypes.dll assembly using the RUT.cs and FUT.cs files. If you build this project and then use ILDasm to examine the metadata, you'll see the FileDef and ExportedTypesDef tables included in the output. Figure 7 shows what those tables look like.
      From this, you can see that RUT.mod is a file that is considered to be part of the assembly. From the ComType table (which is being renamed for Beta 2), you can see that there is a publicly exported type, ARarelyUsedType. The implementation token for this type is 0x26000001, which indicates that the type's MSIL code is contained in the RUT.mod file.
      For the curious, tokens are four-byte values. The high byte indicates the type of token (0x01=TypeRef, 0x02=TypeDef, 0x26=FileRef, 0x27=ExportedType). For the complete list, see the CorTokenType enumerated type in the CorHdr.h file included with the .NET Framework SDK. The low three bytes of the token simply identify the row in the corresponding metadata table, so the implementation token 0x26000001 refers to the second row of the FileDef table.
      To build any client code that consumes the JeffTypes.dll assembly's types requires that the code be built using the /r[eference]:JeffTypes.dll compiler switch. In order to run this client code, the common language runtime requires that JeffTypes.dll be available. However, the RUT.mod file is only required if the client code accesses any of the types contained in the RUT.mod file.
      Instead of using the C# compiler, you may create assemblies using the Assembly Linker utility, AL.exe. The AL.exe utility is useful if you want to create an assembly consisting of modules built from different compilers (if your compiler doesn't support the equivalent of the C# /addmodule switch), or if you just don't know your assembly packaging requirements at build time. You can also use AL.exe to build resource-only assemblies (called satellite assemblies), which are typically used for localization purposes.
      The AL.exe utility can produce an EXE or DLL PE file that contains nothing but a manifest describing the types in other modules. To understand how AL.exe works, let's change the way the JeffType.dll assembly is built:

  csc /out:RUT.mod /t:module RUT.cs
csc /out:FUT.mod /t:module FUT.cs
al /out:JeffTypes.dll /t:lib FUT.mod RUT.mod

      Figure 8 shows the files that result from executing these statements. In Figure 8, I create two separate modules, RUT.mod and FUT.mod, that are not themselves assemblies. Then, I produce a third file, JeffTypes.dll, which is a small DLL PE file (due to the /t[ype]:lib switch) that contains no MSIL code but has manifest metadata tables, indicating that RUT.mod and FUT.mod are part of the assembly.

Figure 8 RUT and FUT Modules and JeffTypes.dll
Figure 8 RUT and FUT Modules and JeffTypes.dll

      The AL.exe utility can also produce CUI and GUI PE files (using the /t[ype]:exe or /t[ype]:win command-line switches), but this is very unusual since this means that you'd have an EXE PE file with just enough MSIL code in it to call a method in another module. This MSIL code is generated by the AL.exe utility when you call it using the /main command-line switch.

     csc /out:App.mod /t:module /r:JeffTypes.dll App.cs   al  /out:App.exe /t:exe /main:App.Main app.mod

      Here, the first line builds the App.cs file into a module. Then, the second line produces a small App.exe PE file that contains the manifest metadata tables. In addition, there is a small global function emitted by AL.exe due to the /main:App.Main switch. This function, called __EntryPoint, contains the following MSIL code:

  .method privatescope static void __EntryPoint() il managed
// Code size 8 (0x8)
.maxstack 8
IL_0000: tail.
IL_0002: call void [.module 'App.mod']App::Main()
IL_0007: ret
} // end of method 'Global
// Functions::__EntryPoint'

As you can see, this code simply calls the Main method contained in the App type defined in the App.mod file.
      Al.exe's /main switch is not that useful since it is unlikely that you'd ever create an assembly for an application where the application's entry point isn't in the PE file that contains the manifest metadata tables. I only mention the switch here to make you aware of its existence.
      When using AL.exe to create an assembly, you may add resource files (non-PE files) to the assembly by using the /embed[resource] switch. This switch takes a file containing resource information and embeds the file's contents into the resulting PE file. Of course, the manifest tables are updated to reflect the existence of the resources. AL.exe also supports a /link[resource] switch that also takes a file containing resources. However, the /link[resource] switch just updates the manifest tables to reflect that there is another file that contains resources. The resource file is not embedded into the assembly PE file; it remains separate and must be packaged with the other assembly files.
      Like AL.exe, CSC.exe also allows you to combine resources into an assembly produced by the C# compiler. The C# compiler's /res[ource] switch embeds the specified resource file into the resulting assembly PE file, while the compiler's /linkres[ource] switch updates the manifest tables to refer to a standalone resource file.
      One last note about resources: it is possible to embed standard Win32 resources into an assembly. You can do this easily by specifying the pathname of a .res file with the /win32res switch when using either AL.exe or CSC.exe. In addition, there is a quick and easy way to embed a standard Win32 icon resource into an assembly file: specify the pathname of an .ico file with the /win32icon switch when using either AL.exe or CSC.exe.

Assembly Version Resource Information

      When AL.exe or CSC.exe produces a PE file assembly, it also embeds into the PE file a standard Win32 Version resource. Users can examine this resource by viewing the file's properties (see Figure 9). In addition, you can use the Microsoft Visual Studio .NET resource editor to view/modify the version resource fields (see Figure 10).

Figure 9 View Properties
Figure 9 View Properties

      When building an assembly, you set the resource fields using custom attributes that you apply at the assembly level in your code. An example of the code needed to produce the version information shown in Figure 10 can be seen in Figure 11 .

Figure 10 Version Information
Figure 10 Version Information

      Figure 12 shows the version resource fields and the corresponding custom attribute. If you are using AL.exe to build your assembly, you may elect to use command-line switches to set this information instead of the custom attributes. Figure 12 shows the AL.exe command-line switch that corresponds to each resource field. Note that the C# compiler doesn't offer these command-line switches and that in general, using custom attributes is the preferred way to set this information.
      In addition to the version resource attributes, you can also use AL.exe's /proc[essor] and /os switches to set entries in the AssemblyProcessorDef and AssemblyOSDef manifest metadata tables, respectively. This information can also be set using the AssemblyOperatingSystemAttribute and AssemblyProcessorAttribute, respectively. Both of these attributes are defined in the System.Reflection namespace. As mentioned earlier, both the AssemblyProcessorDef and AssemblyOSDef manifest metadata tables are currently ignored by the CLR, so these attributes are rarely, if ever, specified.

Simple Application Deployment (Private Assemblies)

      In the previous section, I examined how to build modules and how to combine those modules into an assembly. At this point, I'm ready to package and deploy all of the assemblies so that users can run the application.
      Assemblies do not dictate or require any special means for packaging. The easiest way to package a set of assemblies is simply to copy all the files directly. For example, you could put all the assembly files on a CD-ROM and ship it to the user with a batch file setup program that just copies the files from the CD to a directory on the user's hard disk. Since the assemblies include all of the dependent assembly references and types, the user can just run the application and the runtime will look for referenced assemblies in the application's directory. No modifications to the registry or Active Directoryâ„¢ are necessary for the application to run. To uninstall the application, just delete all the filesâ€"that's it!
      Of course, you may package and install the assembly files using other mechanisms such as .cab files (typically used for Internet download scenarios to compress files and reduce download times). You can also package the assembly files into an MSI file for use by the Windows Installer service (MSIExec.exe version 1.5 or later).
      Note that using a batch file or other simple installation software will get an application onto the user's machine; however, you will need more sophisticated installation software in order to create shortcut links on the user's desktop, Start menu, and Quick Launch toolbar. Also, you can easily backup and restore the application or move the application from one machine to another, but the various shortcut links will require special handling.
      Assemblies that are deployed to the same directory as the application are called private assemblies because the assembly files are not shared with any other application (unless it is also deployed to the same directory). Private assemblies are a big win for developers, users, and administrators since they don't require any updates to registry settings or the Active Directory (because the assemblies are completely self-describing). That is, each type that an assembly references has metadata that allows the runtime to locate the assembly that contains the type. In addition, every type is scoped by the referencing assembly. This means that an application always binds to the exact type that it was built and tested with; the runtime can't load a different assembly that just happens to provide a type with the same name. This is different from unmanaged COM where types are recorded in the registry, making them available to any application running on the machine.

Simple Administrative Control (Configuration)

      There are some aspects about the execution of an application that are best decided by the user or administrator of the application. For example, an administrator may decide to move an assembly's files on the user's hard disk or to override information contained in the assembly's manifest. There are also versioning and remoting issues that have to be resolved. I will discuss versioning in Part 2 of this article.
      To allow administrative control over an application, a configuration file may be placed in the application's directory. This file is created and maintained by the administrator and is used by the runtime to alter policies that are applied to the execution of the application. These configuration files are XML files and can be associated with an application, a specific user, or with the machine. Using a separate file (versus registry settings) allows the file to be easily backed up and allows the administrator to copy the application to another machine: just copy the necessary files and the administrative policy is copied, too.
      Let's say that the administrator wants to deploy your application, but wants to place the JeffTypes assembly files in a different directory than the application's assembly file. The directory structure looks like this:

  AppDir directory (contains the application's assembly files)
AuxFiles subdirectory (contains JeffTypes' assembly files)

      After moving the files, the runtime will be unable to locate the JeffTypes assembly files. To fix this, the administrator creates an XML configuration file. The name of this file must be the name of the application's main assembly file with a .cfg extension: App.cfg, for this example. The configuration file should look like this:

  <?xml version ="1.0"?>
<AppDomain PrivatePath="AuxFiles"/>

      Now, whenever the runtime attempts to locate an assembly file, the runtime always looks in the application's directory first, and if it can't find the file there, it looks in the AuxFiles subdirectory. You can specify multiple semicolon-delimited paths for the PrivatePath property. Each path is considered relative to the application's starting directory. You cannot specify absolute pathsâ€"all assemblies must be in the application's starting directory or in a subdirectory of that starting directory.


      Windows has a reputation for making it difficult to manage and administer software installation. Over the years, Microsoft has applied many band-aids (such as version resources and the relatively new Microsoft Installer engine, MSI) to Windows in order to fix these problems. While these technologies have helped, the problems have not been solved. Personally, I don't believe that these problems are completely curable, but metadata and assemblies are a big step towards improving system stability. And, private assemblies (which do not require registry settings) are a big step towards reducing management and administration headaches.
      In Part 2 of this article I'll explain how to build assemblies that can be shared by multiple apps. I will also explain how the CLR locates the assemblies containing a reference type.

For background information see:
How the Runtime Locates Assemblies 

Jeffrey Richter ( ) is the author of* Programming Applications for Microsoft Windows *(Microsoft Press, 1999), and is a co-founder of Wintellect (, a software education, debugging, and consulting firm. He specializes in programming/design for .NET and Win32. Jeff is currently writing a Microsoft .NET Framework programming book and offers .NET technology seminars.

From the February 2001 issue of MSDN Magazine