Unit Testing Principles, Practices, and Patterns: Takeaways

This book won’t teach you how to write a unit test step by step. But, it will teach you how unit testing fits the larger picture of a software project. Also, this book shows how to write integration tests and test the database. These are my takeaways.

1. What is a unit test?

“The goal of unit testing is to enable sustainable growth of the software project.” “It’s easy to fall into the trap of writing unit tests for the sake of unit testing without a clear picture of whether it helps the project.”

A successful test suite has the following properties:

  • It’s integrated into the development cycle,
  • It targets only the most important parts of your codebase: the domain model,
  • It provides maximum value with minimum maintenance costs.

A unit test is an automated test with three attributes:

  1. It verifies a small portion of behavior (a unit),
  2. does it quickly, and,
  3. in isolation from other tests

There are two groups of developers with different views about “isolation”: the London school and the Classical school.

For the London school, isolation means writing separate tests for separate classes. If a class has collaborators, we should test it using test doubles for every collaborator.

On the other hand, for the Classical school, it’s not the code that needs to be tested in isolation, but the tests. They should run in isolation from each other. It’s ok to test more than one class at a time if the tests don’t affect others by sharing state.

“A test—whether a unit test or an integration test—should be a simple sequence of steps with no branching”

A good unit test has these four attributes:

  1. Protection against regressions: “Code that represents complex business logic is more important than boilerplate code.”
  2. Resistance to refactoring: “The more the test is coupled to the implementation details of the system under test (SUT), the more false alarms it generates.”
  3. Fast feedback: “The faster the tests, the more of them you can have in the suite and the more often you can run them.”
  4. Maintainability: “How hard is to read a test” and “how hard is to run a test.”
Car cashed into a wall
That's a failing test. Photo by Gareth Harrison on Unsplash

2. What code to test?

Not all code is created equal and worth the same.

Types of Code based on complexity and number of dependencies
Types of Code by Complexity and Number of Dependencies

There are four types of code:

  1. Domain logic and algorithms: Complex code by nature
  2. Trivial code: Constructors without parameters and one-line properties
  3. Controllers: Code with no business logic that coordinates other pieces
  4. Overcomplicated code: Complex code with too many dependencies

Write unit tests for your domain model and algorithms. It gives you the best return for your efforts. Don’t test trivial code. Those tests have a close-to-zero value.

“Your goal is a test suite where each test adds significant value to the project. Refactor or get rid of all other tests. Don’t allow them to inflate the size of your test suite”

3. What is an integration test? And how to test the database?

An integration test is any test that is not a unit test. In the sense of verifying a single behavior, doing it quickly and in isolation from other tests.

Write integration tests to cover the longest happy path and use the same code that the “controllers” use.

Before writing integration tests for the database:

  • Keep the database in the source control system. It keeps track of changes and makes the code the single source of truth
  • Make reference data part of the database schema
  • Have every developer roll a separate instance
  • Use migration-based database delivery. Store your migrations in your version control system.

When writing integration tests for the database:

  • Separate database connections from transactions. Use repositories and transactions.
  • Don’t reuse database transactions or units of work between sections of the test. Integration tests should replicate the production environment as closely as possible. This means the Act part shouldn’t share connections or database context with anyone else.
  • Clean up data at the beginning of each test. Create a base class and put all the deletion scripts there.
  • Don’t use in-memory databases. They don’t have the same set of features. Use the same database system as production.
  • Extract technical, non-business-related parts into helper methods. For Arrange parts, use object mothers. And, for Assert parts, create extension methods for data assertions, like, userFromDb.ShouldExist().
  • Test only the most complex or important read operations. Forget about the rest.
  • Don’t test repositories directly. Test them as part of an overarching integration test suite.

Voilà! These are my takeaways. Although this book has “Unit Testing” in its title, I really liked it covers integration tests, especially testing the database and data-access layer. I’d say this isn’t a book for beginners. You would take more out of this book if you read The Art Of Unit Testing first.

If you want to read more about unit testing, check my Unit Testing 101 series where I cover from what unit testing is to unit testing best practices.

Want to write readable and maintainable unit tests in C#? Join my course Mastering C# Unit Testing with Real-world Examples on Udemy and learn unit testing best practices while refactoring real unit tests from my past projects. No more tests for a Calculator class.

Happy testing!

Hands-on Domain-Driven Design with .NET Core: Takeaways

If you’re new to Domain-Driven Design, this book is a good starting point. It’s a “hands-on” book. It walks through a sample marketplace for ads. It shows from what Domain-Driven Design is to how to evolve a system. Also, it contains a couple of chapters with a good introduction to Event Sourcing. These are my takeaways.

DDD and Ubiquitous Language

The main point of Domain-Driven Design (DDD) is sharing the domain language between domain experts and developers in meetings, documentation, and even in code. That’s what we call a “Ubiquitous Language.”

In our code, we should make all domain concepts explicit and express intent clearly. For example, in a software system to register Paid time off, what do StartDate, EndDate, and HalfDate mean? Does StartDate refer to the last day at work or the first non-working day? What about: FirstDayNotAtWork, CameBackToWork, and LeftDuringWorkday?

A ubiquitous language makes sense in a context. For example, a product doesn’t mean the same thing for the Sales, Purchasing, and Inventory departments.

Therefore, we should avoid God-classes like Product or Customer with properties for all possible views of the physical object, since not all properties need to be populated at a given time.

Half of a red onion on a white background
Does your architecture make you cry too? Photo by K8 on Unsplash

Onion Architecture and CQRS

This book advocate using the Onion Architecture and Command-query Responsibility Segregation (CQRS) when implementing DDD.

When following the Onion Architecture, the Domain is the center of everything, and everything depends on it. Application services and Infrastructure are layers around this core. Apart from standard libraries and some base classes, the Domain shouldn’t have any references.

“A good rule of thumb here is that the whole domain model should be testable without involving any infrastructure. Primarily, in your domain model tests, you should not use test harnesses and mocks.”

CQRS distinguishes between write and read operations using commands that mutate the system and queries that return the system state.

CQRS commands and queries
CQRS commands and queries flow

To implement CQRS, we can use database-mapped domain objects to mutate the system and SQL queries to retrieve the system, ignoring the domain model.

DDD Mechanics

To implement a domain model in code, DDD has some recognizable types of objects like entities, value objects, events, and services.

Entities should have an Id, accessible from the outside. IDs are database unique keys or GUIDs. We shouldn’t change an entity by changing its properties from outside the entity.

Value objects should be immutable. Two entities are the same by their identity but value objects by their value. To validate entity invariants, we can use a EnsureValidState() method.

Events are reactions to executions of commands. Events represent data in commands and other details from the changed entity, like the Id. Events should only contain primitive types. With events, we can notify changes in one part of the system.

Application services accept commands and use the Domain to handle the operation. An application service is responsible for translating primitive types to value objects. Often, all application services follow a similar script: they retrieve an entity from the database, mutate it and update the database.

Aggregate Roots work like a parent entity that changes its state as a whole. We should only reference, access, and manipulate child objects of an aggregate through the aggregate boundary.

Queries, Repositories, and Databases

“A domain model exists on its own, and it is designed to deal with business rules and invariants, and not to deal with the database.”

There’s a distinction between repositories and queries. Repositories deal with the aggregate state. In a ClassifiedAdRepository, we only should get ClassifiedAds. For all other data access, we should use queries.

We should write queries using the Ubiquitous Language too. For example, let’s write GetAdsPendingReview() instead of GetAds(ad => ad.State == State.PendingReview). And we can access the storage directly on our query handlers. That’s fine.

For example, this is a query to return active classified ads. We can put it inside the API layer directly,

public static class Queries
{
    public static async Task<IEnumerable<PublicClassifiedAdListItem>> QueryPublishedClassifiedAds(
        this DbConnection someDbConnection,
        QueryModels.GetPublishedClassifiedAds query)
    {
        await someDbConnection.QueryAsync<PublicClassifiedAdListItem>("Plain old SQL query",
            new
            {
                State = (int)ClassifiedAdState.Active,
                PageSize = query.PageSize,
                Offset = Offset(query.Page, query.PageSize)
            });
    }
}

I really like the simplicity of using queries instead of too many artifacts and layers of indirection to read data.

Parting Thoughts

Voilà! Those are my takeaways. I’d say it’s a good book to learn about DDD for the first time. There are things I liked and didn’t like about this book.

I liked that the book contains a practical example, a marketplace for ads, not only theory. If you want to follow along with the code sample, read these chapters: 4, 5, 6, 7, and 9. Skip most of chapter 8 if you already know how to set up EntityFramework. Skim through all others.

I liked how the sample application doesn’t use interfaces just for the sake of it. I’ve seen so many single-implementation interfaces to only use a dependency container and test it with mocks. And I also liked how query handlers use SQL statements directly instead of using another layer of indirection.

But, I didn’t like that the sample application ended up with “application services” instead of “command handlers.” I was expecting a command handler per each command and API method. The only sample application service has a huge switch statement to handle every command. Argggg!

For more takeaways, check Domain Modeling Made Functional: Takeaways. Don’t miss A case of Primitive Obsession, it shows how to put in place classes (or records) to replace primitive types. And, my heuristics to choose Value Objects.

Happing coding!

Monday Links: Time zones and NDC Conference

Last month I followed the NDC Conference on YouTube. In this Monday Links episode, I share some of the conferences I watched and liked. I don’t know why but I watched presentations about failures, aviation disasters, and software mistakes. Well, two of the 5 links aren’t about that. Enjoy!

Improve working across time zones

Prefer document-based over meeting-based documentation. Only schedule meetings for discussions and have a clear agenda for everyone to review before the meeting. After the meeting, share the conclusions with people in different time zones who couldn’t join. Read full article

Mayday! Software lessons from aviation disasters

This is a conference from NDC. It shows two case studies from aviation disasters and how they relate to software engineering. For the first case study, after an incident, a security expert asked his team these questions to identify the cause of the incident:

  • How can I prove myself wrong?
  • What details might I be ignoring because it doesn’t fit my theory or solution?
  • What else could cause this issue or situation?

Experts traced the root of the incident ten years before the crash: counterfeit parts. This makes us wonder about counterfeit code: code we copy from StackOverflow, blogs, and documentation. We’re responsible for every line of code we write, even for the ones we copy and paste.

The second case study teaches us some good lessons about communication.

Failure is Always an Option

From space accidents to the British Post Office to a Kenya money transfer company, this talk shows how new businesses and branches of Science come out of failures and unanticipated usages of systems. Inspired by and contradicting one line in the Apollo 13 movie, “Failure is not an option.”

This talk claims that the single point of failure of modern cloud-based solutions is the credit card paying the cloud provider. LOL!

Hacking C#: Development for the Truly Lazy

This talk shows a bag of tricks to make code more readable. It shows how to use C# extension methods to remove duplication. Also, it presents the “Commandments of Extension Methods:”

  • No business logic
  • Keep them as small as possible
  • Keep them generic, so you can use them with any object
  • Keep them portable
  • Use them where there is boring and repetitive code
  • Make them useful

Ah! I learned we can make indexers receive multiple indexes. Like something[1, 3, 5].

Programming’s Greatest Mistakes

I had a coworker that always said: “Nobody is going to die,” when somebody else was reluctant to change some code. It turned out we weren’t working on a medical or aerospatial domain. But often, oops cause businesses to lose money. I bet you have taken down servers because of an unoptimized SQL query. That happened to a friend of a friend of mine. Wink, wink!

It starts by showing one stupid mistake the author made in his early days using a sarcastic name for one of his support tools. The support team ended up shipping it to their clients. Y2K, a missing using in a mission-critical software, null, and other mistakes.

Voilà! Do you also follow the NDC Conference? What are your own programming’s greatest mistakes? Don’t be ashamed. All of us have one. Until next Monday Links!

In the meantime, check my Getting Started with LINQ course where I cover from what LINQ is to its most recent methods and overloads introduced in .NET6. And don’t miss the previous Monday Links on Storytelling, Leet Code, and Boredom.

Happy coding!

Three set-like LINQ methods: Intersect, Union, and Except

So far we have covered some of the most common LINQ methods. This time let’s cover three LINQ methods that work like set operations: Intersect, Union, and Except.

Like the Aggregate method, we don’t use these methods every day, but they will come in handy from time to time.

1. Intersect

Intersect() finds the common elements between two collections.

Let’s find the movies we both have watched and rated in our catalogs.

var mine = new List<Movie>
{
    // We have not exactly a tie here...
    new Movie("Terminator 2", 1991, 4.7f),
    //        ^^^^^^^^^^^^^^
    new Movie("Titanic", 1998, 4.5f),
    new Movie("The Fifth Element", 1997, 4.6f),
    new Movie("My Neighbor Totoro", 1988, 5f)
    //        ^^^^^^^^^^^^^^^^^^^^
};

var yours = new List<Movie>
{
    new Movie("My Neighbor Totoro", 1988, 5f),
    //        ^^^^^^^^^^^^^^^^^^^^
    new Movie("Pulp Fiction", 1994, 4.3f),
    new Movie("Forrest Gump", 1994, 4.3f),
    // We have not exactly a tie here...
    new Movie("Terminator 2", 1991, 5f)
    //        ^^^^^^^^^^^^^^
};

var weBothHaveSeen = mine.Intersect(yours);
Console.WriteLine("We both have seen:");
PrintMovies(weBothHaveSeen);

// Output:
// We both have seen:
// My Neighbor Totoro

static void PrintMovies(IEnumerable<Movie> movies)
{
    Console.WriteLine(string.Join(",", movies.Select(movie => movie.Name)));
}

record Movie(string Name, int ReleaseYear, float Rating);

This time, we have two lists of movies, mine and yours, with the ones I’ve watched and the ones you have watched, respectively. Also, we both have watched “My Neighbor Totoro” and “Terminator 2.”

To find the movies we both have seen (the intersection between our two catalogs), we used Intersect().

But, our example only shows “My Neighbor Totoro.” What happened here?

If we pay close attention, we both have watched “Terminator 2,” but we gave it different ratings. Since we’re using records from C# 9.0, records have member-wise comparison. Therefore, our two “Terminator 2” instances aren’t exactly the same, even though they have the same name. That’s why Intersect() doesn’t return it.

To find the common movies using only the movie name, we can:

  • pass a custom comparer to Intersect(),
  • override the default Equals() and GetHashCode() methods of the Movie record, or,
  • use the new IntersectBy() method introduced in .NET6.

Let’s use the IntersectBy() method.

var weBothHaveSeen = mine.IntersectBy(
        yours.Select(yours => yours.Name),
        //    ^^^^^^
        // Your movie names
        (movie) => movie.Name);
        //               ^^^^
        // keySelector: Property to compare by

Console.WriteLine("We both have seen:");
PrintMovies(weBothHaveSeen);

// Output:
// We both have seen:
// Terminator 2,My Neighbor Totoro

Unlike Intersect(), IntersectBy() expects a “keySelector,” a delegate with the property to use as the comparing key, and a second collection with the same type as the keySelector.

Colorful apartments in a building
Photo by Martin Woortman on Unsplash

2. Union

Union() finds the elements from both collections without duplicates.

Let’s find all the movies we have in our catalogs.

var mine = new List<Movie>
{
    new Movie("Terminator 2", 1991, 5f),
    //        ^^^^^^^^^^^^^^
    new Movie("Titanic", 1998, 4.5f),
    new Movie("The Fifth Element", 1997, 4.6f),
    new Movie("My Neighbor Totoro", 1988, 5f)
    //        ^^^^^^^^^^^^^^^^^^^^
};

var yours = new List<Movie>
{
    new Movie("My Neighbor Totoro", 1988, 5f),
    //        ^^^^^^^^^^^^^^^^^^^^
    new Movie("Pulp Fiction", 1994, 4.3f),
    new Movie("Forrest Gump", 1994, 4.3f),
    new Movie("Terminator 2", 1991, 5f)
    //        ^^^^^^^^^^^^^^
};

var allTheMoviesWeHaveSeen = mine.Union(yours);
Console.WriteLine("All the movies we have seen:");
PrintMovies(allTheMoviesWeHaveSeen);

// Output:
// All the movies we have seen:
// Terminator 2,Titanic,The Fifth Element,My Neighbor Totoro,Pulp Fiction,Forrest Gump

static void PrintMovies(IEnumerable<Movie> movies)
{
    Console.WriteLine(string.Join(",", movies.Select(movie => movie.Name)));
}

record Movie(string Name, int ReleaseYear, float Rating);

This time we gave the same rating to our shared movies: “Terminator 2” and “My Neighbor Totoro.” And, Union() showed all the movies from both collections, showing duplicates only once.

Union() works the same way as the union operation in our Math classes.

LINQ has a similar method to “combine” two collections into a single one: Concat(). But, unlike Union(), Concat() returns all elements from both collections without removing the duplicated ones.

.NET 6.0 also has a UnionBy() method to “union” two collections with a keySelector. And, unlike IntersectBy(), we don’t need the second collection to have the same type as the keySelector.

3. Except

Except() finds the elements in one collection that are not present in another one.

This time, let’s find the movies only I have watched.

var mine = new List<Movie>
{
    new Movie("Terminator 2", 1991, 5f),
    new Movie("Titanic", 1998, 4.5f),
    //         ^^^^^^^
    new Movie("The Fifth Element", 1997, 4.6f),
    //         ^^^^^^^^^^^^^^^^^
    new Movie("My Neighbor Totoro", 1988, 5f)
};

var yours = new List<Movie>
{
    new Movie("My Neighbor Totoro", 1988, 5f),
    new Movie("Pulp Fiction", 1994, 4.3f),
    new Movie("Forrest Gump", 1994, 4.3f),
    new Movie("Terminator 2", 1991, 5f)
};

var onlyIHaveSeen = mine.Except(yours);
Console.WriteLine();
Console.WriteLine("Only I have seen:");
PrintMovies(onlyIHaveSeen);

// Output:
// Only I have seen:
// Titanic,The Fifth Element

static void PrintMovies(IEnumerable<Movie> movies)
{
    Console.WriteLine(string.Join(",", movies.Select(movie => movie.Name)));
}

record Movie(string Name, int ReleaseYear, float Rating);

With Except(), we found the movies in mine that are not in yours.

When working with Except(), we should pay attention to the order of the collection because this method isn’t commutative. This means, mine.Except(yours) is not the same as yours.Except(mine).

Likewise, we have ExceptBy() that receives a KeySelector and a second collection with the same type as the keySelector type.

Voilà! These are the Intersect(), Union(), and Except() methods. They work like the Math set operations: intersection, union, and symmetrical difference, respectively. Of the three, I’d say Except is the most common method.

If you want to read more about LINQ, check my quick guide to LINQ, five common LINQ mistakes and how to fix them and what’s new in LINQ with .NET6.

Want to write more expressive code for collections? Join my course, Getting Started with LINQ on Udemy and learn everything you need to know to start working productively with LINQ—in less than 2 hours.

Happy coding!

NCache & Full-Text Search

I bet you have used the SQL LIKE operator to find a keyword in a text field. For large amounts of text, that would be slow. Let’s learn how to implement a full-text search with Lucene and NCache.

Full-text search is a technique to search not only exact matches of a keyword in some text but for patterns of text, synonyms, or close words in large amounts of text.

To support large amounts of text, searching is divided into two phases: indexing and searching. In the indexing phase, an analyzer processes text to create indexes based on the rules of a spoken language like English to remove stop words and record synonyms and inflections of words. Then, the searching phase only uses the indexes instead of the original text source.

Full-Text Search with Lucene and NCache

1. Why Lucene and NCache?

From its official page, “Apache Lucene.NET is a high performance search library for .NET.” It’s a C# port of Java-based Apache Lucene, an “extremely powerful” and fast search library optimized for full-text search.

NCache gives distributed capabilities to Lucene by implementing the Lucene API on top of its In-Memory Distributed cache. This way, NCache makes Lucene a linearly scalable full-text searching solution for .NET. For more features of Distributed Lucene, check NCache Distributed Lucene page.

2. Create a Lucene Cache in NCache

We have already installed and used NCache as a IDistributedCache provider. This time, let’s use NCache version 5.3 to find movies by title or director name using Lucene’s full-text search.

Lucene stores data in immutable “segments,” which consist of multiple files. We can store these segments in our local file system or in RAM. But, since we’re using Lucene with NCache, we’re storing these segments in NCache.

Before indexing and searching anything, first, we need to create a Distributed Lucene Cache. Let’s navigate to http://localhost:8251 to fire NCache Web Manager and add a New Distributed Cache.

Let’s select “Distributed Lucene” in the Store Type and give it a name. Then, let’s add our own machine and a second node. For write operations, we need at least two nodes. We can stick to the defaults for the other options.

By default, in Windows machines, NCache stores Lucene indexes in C:\ProgramData\ncache\lucene-index.

NCache Store Type as Distributed Lucene
NCache Store Type as Distributed Lucene

For more details about these installation options, check NCache official docs.

Movie theater
Let's index some movies, shall we? Photo by Jake Hills on Unsplash

3. Index Some Movies

After creating the Distributed Lucene cache, let’s populate our Lucene indexes with some movies from a Console app. Later, we will search them from another Console app.

First, let’s create a Console app to load some movies to the Lucene Cache. Also, let’s install the Lucene.Net.NCache NuGet package.

In the Program.cs file, we could load all movies we want to index from a database or another store. For example, let’s use a list of movies from IMDb. Something like this,

using SearchMovies.Shared;
using SearchMovies.Shared.Entities;
using SearchMovies.Shared.Services;

var searchService = new SearchService(Config.CacheName);
searchService.LoadMovies(SomeMoviesFromImdb());

Console.WriteLine("Press any key to continue...");
Console.ReadKey();

// This list of movies was taken from IMDb dump
// See: https://www.imdb.com/interfaces/
static IEnumerable<Movie> SomeMoviesFromImdb()
{
    return new List<Movie>
    {
        new Movie("Caged Fury", 1983, 3.8f, 89, new Director("Maurizio Angeloni", 1959), new []{ Genre.Crime,Genre.Drama  }),
        new Movie("Bad Posture", 2011, 6.5f, 93, new Director("Jack Smith", 1932), new []{ Genre.Drama,Genre.Romance  }),
        new Movie("My Flying Wife", 1991, 5.5f, 91, new Director("Franz Bi", 1899), new []{ Genre.Action,Genre.Comedy,Genre.Fantasy  }),
        new Movie("Modern Love", 1990, 5.2f, 105, new Director("Sophie Carlhian", 1962), new []{ Genre.Comedy  }),
        new Movie("Sins", 2012, 2.3f, 84, new Director("Pierre Huyghe", 1962), new []{ Genre.Action, Genre.Thriller  })
        // Some other movies here...
    };
}

Notice we used a SearchService to handle the index creation in a method called LoadMovies(). Let’s take a look at it.

using Lucene.Net.Analysis.Standard;
using Lucene.Net.Index;
using Lucene.Net.Store;
using Lucene.Net.Util;
using SearchMovies.Shared.Entities;
using SearchMovies.Shared.Extensions;

namespace SearchMovies.Shared.Services;

public class SearchService
{
    private const string IndexName = "movies";
    private const LuceneVersion luceneVersion = LuceneVersion.LUCENE_48;

    private readonly string _cacheName;

    public SearchService(string cacheName)
    {
        _cacheName = cacheName;
    }

    public void LoadMovies(IEnumerable<Movie> movies)
    {
        using var indexDirectory = NCacheDirectory.Open(_cacheName, IndexName);
        // 1. Opening directory    ^^^

        var standardAnalyzer = new StandardAnalyzer(luceneVersion);
        var indexConfig = new IndexWriterConfig(luceneVersion, standardAnalyzer)
        {
            OpenMode = OpenMode.CREATE
        };
        using var writer = new IndexWriter(indexDirectory, indexConfig);
        // 2. Creating a writer   ^^^

        foreach (var movie in movies)
        {
            var doc = movie.MapToLuceneDocument();
            writer.AddDocument(doc);
            //     ^^^^^^^^^^^
            // 3. Adding a document
        }

        writer.Commit();
        //     ^^^^^^
        // 4. Writing documents
    }
}

A bit of background first, Lucene uses documents as the unit of search and index. Documents can have many fields, and we don’t need a schema to store them.

We can search documents using any field. Lucene will only return those with that field and matching data. For more details on some Lucene internals, check its Lucene Quick Start guide.

Notice we started our LoadMovies by opening an NCache directory. We needed the same cache name we configured before and an index name. Then we created an IndexWriter with our directory and some configurations, like a Lucene version, an analyzer, and an open mode.

Then, we looped through our movies and created a Lucene document for each one using the MapToLuceneDocument() extension method. Here it is,

using Lucene.Net.Documents;
using SearchMovies.Shared.Entities;

namespace SearchMovies.Shared.Extensions;

public static class MoviesExtensions
{
    public static Document MapToLuceneDocument(this Movie self)
    {
        return new Document
        {
            new TextField("name", self.Name, Field.Store.YES),
            new TextField("directorName", self.Director.Name, Field.Store.YES)
        };
    }
}

To create Lucene documents, we used two fields of type TextField: movie name and director name. For each field, we need a name and a value to index. We will use the field names later to create a response object from search results.

There are two basic field types for Lucene documents: TextField and StringField. The first one has support for Full-Text search and the second one supports searching for exact matches.

Once we called the Commit() method, NCache stored our movies in a distributed index.

4. Full-Text Searching Movies

Now that we populated our index with some movies, to search them, let’s create another Console app to read a Lucene query.

Again, let’s use the same SearchService, this time with a SearchByNames() method passing a Lucene query.

using Lucene.Net.Analysis.Standard;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Store;
using Lucene.Net.Util;
using SearchMovies.Shared.Entities;
using SearchMovies.Shared.Extensions;
using SearchMovies.Shared.Responses;

namespace SearchMovies.Shared.Services;

public class SearchService
{
    // Same SearchService as before...

    public IEnumerable<MovieResponse> SearchByNames(string searchQuery)
    {
        using var indexDirectory = NCacheDirectory.Open(_cacheName, IndexName);
        using var reader = DirectoryReader.Open(indexDirectory);
        //                 ^^^^^^^^^^^^^^^
        // 1. Creating a reader
        var searcher = new IndexSearcher(reader);

        var analyzer = new StandardAnalyzer(luceneVersion);
        var parser = new QueryParser(luceneVersion, "name", analyzer);
        var query = parser.Parse(searchQuery);
        //          ^^^^^^
        // 2. Parsing a Lucene query 

        var documents = searcher.Search(query, 10);
        //              ^^^^^^^^
        // 3. Searching documents

        var result = new List<MovieResponse>();
        for (int i = 0; i < documents.TotalHits; i++)
        {
            var document = searcher.Doc(documents.ScoreDocs[i].Doc);
            result.Add(document.MapToMovieResponse());
            //     ^^^
            // 4. Populating a result object
        }

        return result;
    }
}

This time, instead of creating an IndexWriter, we used a DirectoryReader and a query parser with the same Lucene version and analyzer. Then, we used the Search() method with the parsed query and a result count. The next step was to loop through the results and create a response object.

To create a response object from a Lucene document, we used the MapToMovieResponse(). Here it is,

public static MovieResponse MapToMovieResponse(this Document self)
{
    return new MovieResponse(self.Get("name"), self.Get("directorName"));
}

This time, we used the Get() method with the same field names as before to retrieve fields from documents.

For example, let’s find all movies whose director’s name contains “ca”, with the query directorName:ca*,

Movies with director name contains 'ca'
Movies with director name contains 'ca'

Of course, there are more keywords in Lucene Query Syntaxt than the ones we used here.

Voilà! That’s how to use Distributed Lucene with NCache. If we already have an implementation with Lucene.NET, we would need few code changes to migrate it to Lucene with NCache. Also, notice that NCache doesn’t implement all Lucene methods.

To follow along with the code we wrote in this post, check my Ncache Demo repository over on GitHub.

canro91/NCacheDemo - GitHub

To read more content, check my post Working with ASP.NET Core IDistributedCache Provider for NCache to learn about caching with ASP.NET Core and NCache.

I wrote this post in collaboration with Alachisoft, NCache creators.

Happy coding!