Product Inventory Manager with MFC

Build a Windows desktop inventory app with MFC, SQLite, and Modern C++ RAII patterns.

Introduction

30 Second Summary

Every business tracks products. From the corner store counting items on shelves to warehouses managing thousands of SKUs, there is always a desktop application behind the scenes that needs to be fast, reliable, and persistent.

In this project, you will build a Product Inventory Manager desktop application using MFC and Modern C++. You'll create a type-safe RAII database wrapper, implement full CRUD operations backed by SQLite, and display products in a professional sortable grid.

What You'll Build

A desktop Product Inventory Manager that displays a sortable product list (Name, SKU, Category, Quantity, Price) backed by a SQLite database, with Add, Edit, and Delete operations through a validated dialog form.

By the end of this project, you'll have:

  • A working MFC dialog-based application with a sortable product grid using CMFCListCtrl in report view.
  • A reusable RAII database wrapper built with std::unique_ptr custom deleters and type-safe parameter binding.
  • A typed ProductRepository class with full CRUD operations and persistent SQLite storage across sessions.
  • Secret Mission: Implement Undo/Redo using the Command pattern with polymorphic command objects and std::stack.

Are there any prerequisites?

This project assumes solid C++ experience (C++17 minimum). You should already have Visual Studio 2026 Community installed with the Desktop development with C++ workload.

Step 1 will walk you through verifying the C++ MFC for latest v143 build tools component is installed and downloading the SQLite amalgamation.

Scaffold the Project and Integrate SQLite

Every Windows desktop application starts with a project scaffold that sets up the window framework. Our inventory manager needs two foundations: an MFC dialog to host the UI and a SQLite database to persist product data.

In this step, you will create the MFC dialog-based project in Visual Studio, drop the SQLite amalgamation source files into it, and configure the compiler for C++17. By the end, you will have a building project ready for real code.

In this step, get ready to:
  • Create the MFC dialog-based project.
  • Add the SQLite amalgamation files to the project.
  • Set the C++ Language Standard to C++17.
Create the MFC dialog-based project

The MFC Application Wizard generates all the boilerplate code for a Windows dialog window. You will choose the "Dialog based" application type, which gives you a simple dialog class without the overhead of the Document/View architecture.

  • In Visual Studio, click File in the top menu bar.
  • Click New, then click Project.
  • In the search box at the top, type MFC and press Enter.
  • Select MFC App from the results list and click Next.
  • In the Project name field, type InventoryManager.
  • Click Create to open the MFC Application Wizard.

What is the MFC Application Wizard?

The wizard generates a full skeleton application including source files, resource files, header files, and a project file. For a dialog-based app, it creates a main dialog class derived from CDialogEx with OK and Cancel buttons ready to go.

  • In the MFC Application Wizard, find the Application type dropdown and select Dialog based.
  • If you see a Use a ribbon checkbox, uncheck it.
  • Accept all other defaults and click Finish.
  • Build and run the project by pressing Ctrl+F5.

You should see a default dialog window appear with "TODO: Place dialog controls here" text and OK and Cancel buttons.

Don't see the MFC App template?

The MFC App template only appears if the C++ MFC component is installed. Open the Visual Studio Installer, click Modify on your installation, expand Desktop development with C++, and confirm that C++ MFC for latest v143 build tools (x86 & x64) is checked.

help me find the MFC App template in Visual Studio 2026

Add the SQLite amalgamation to the project

The SQLite amalgamation packs the entire database engine into a single C source file. Adding it directly to your project means no DLLs, no linker configuration, and no external dependencies to manage.

  • Open File Explorer and navigate to the folder where your InventoryManager project was created. You will find a subfolder also named InventoryManager containing the .cpp and .h files.
  • Inside that inner InventoryManager folder, create a new folder named sqlite.
  • Open the sqlite-amalgamation-3530300.zip file you downloaded earlier.
  • Extract sqlite3.c and sqlite3.h into the sqlite folder you just created.

Why use the amalgamation?

The SQLite amalgamation concatenates over 100 separate source files into a single C file. This makes integration trivial. It also enables the compiler to perform more aggressive optimizations across the full library, yielding 5-10% better performance.

  • Switch back to Visual Studio.
  • In Solution Explorer, right-click the InventoryManager project node.
  • Click Add, then click Existing Item.
  • Navigate to the sqlite folder, select both sqlite3.c and sqlite3.h, and click Add.

Now you need to disable precompiled headers for sqlite3.c. The MFC wizard generates a precompiled header (pch.h) that all .cpp files include. Since sqlite3.c is plain C code, it does not include pch.h and will fail to compile without this change.

  • In Solution Explorer, right-click sqlite3.c and select Properties.
  • Expand Configuration Properties, then expand C/C++, then click Precompiled Headers.
  • Set the Precompiled Header dropdown to Not Using Precompiled Headers.
  • Set the Configuration dropdown at the top to All Configurations and Platform to All Platforms before clicking OK. This ensures the setting applies to both Debug and Release builds.
  • Build the project by pressing Ctrl+Shift+B.

The build should succeed with no errors. You may see some warnings from the SQLite source. These are safe to ignore.

Seeing a fatal error about pch.h?

If you see an error mentioning a missing precompiled header file, you likely did not apply the setting to All Configurations and All Platforms. Re-open the properties for sqlite3.c, set both dropdowns at the top, then set Precompiled Header to Not Using Precompiled Headers again.

help me fix a precompiled header error for sqlite3.c in my MFC project

Set the C++ Language Standard to C++17

The RAII wrappers you will build in the next step use std::optional and structured bindings, both of which require C++17. You will configure this at the project level for all configurations.

  • In Solution Explorer, right-click the InventoryManager project and select Properties.
  • Set the Configuration dropdown to All Configurations and Platform to All Platforms.
  • Expand Configuration Properties, then expand C/C++, then click Language.
  • Set C++ Language Standard to ISO C++17 Standard (/std:c++17).
  • Click OK to close the Property Pages.
  • Build the project again by pressing Ctrl+Shift+B.

The build should succeed. Your project is now configured for C++17 across all build configurations.

Build still failing?

Double-check that you selected All Configurations and All Platforms before changing the language standard. If you only set it for Debug, the Release build will use the default standard and may produce errors when you add C++17 code later.

help me set C++17 for all configurations in Visual Studio

Your project foundation is solid. You have an MFC dialog application that builds cleanly with SQLite compiled directly into it. Next up, you will build the RAII database wrapper that gives you type-safe, exception-safe access to SQLite from Modern C++.

Build the RAII Database Wrapper

Your MFC project compiles and the SQLite amalgamation is linked in. Now you need a way to talk to SQLite from modern C++ without leaking resources or writing manual cleanup code.

Raw SQLite C pointers (sqlite3*, sqlite3_stmt*) require you to remember to call sqlite3_close and sqlite3_finalize at every exit path. In this step you will build a type-safe RAII wrapper that eliminates that entire class of bugs by encoding cleanup in std::unique_ptr custom deleters.

In this step, get ready to:
  • Create a Database class that opens and closes SQLite connections automatically.
  • Create a Statement class with type-safe parameter binding and column extraction.
  • Initialize the Products table schema on app startup.
Create the Database class with RAII connection management

The Database class owns the sqlite3* connection handle. Its custom deleter calls sqlite3_close when the object goes out of scope, guaranteeing cleanup even if an exception is thrown.

  • In Solution Explorer, right-click **Header Files**, then click **Add**, then **New Item**.
  • Select **Header File (.h)**, name it Database.h, and click **Add**.
  • Replace the empty file contents with the following code:
#pragma once
#include <string>
#include <memory>
#include <stdexcept>
#include "sqlite/sqlite3.h"

class Statement;

class Database {
public:
    explicit Database(const std::string& path);
    ~Database() = default;

    // Non-copyable
    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;
    // Moveable
    Database(Database&&) = default;
    Database& operator=(Database&&) = default;

    void execute(const std::string& sql);
    Statement prepare(const std::string& sql);
    int64_t lastInsertRowId() const;

    sqlite3* raw() const { return m_db.get(); }

private:
    // Custom deleter calls sqlite3_close on destruction
    struct Deleter {
        void operator()(sqlite3* db) const {
            if (db) sqlite3_close(db);
        }
    };
    std::unique_ptr<sqlite3, Deleter> m_db;
};

What does this code do?

The Deleter struct is a callable that std::unique_ptr invokes when the pointer is released. This means sqlite3_close is called automatically, no matter how control leaves the scope.

Copy and move constructors are deleted/defaulted respectively. This prevents accidental copies (double-close) while still allowing the Database to be moved into containers or returned from factory functions.

  • Now create the implementation file. Right-click **Source Files**, then click **Add**, then **New Item**.
  • Select **C++ File (.cpp)**, name it Database.cpp, and click **Add**.
  • Paste the following implementation:
#include "pch.h"
#include "Database.h"
#include "Statement.h"

Database::Database(const std::string& path) {
    sqlite3* raw = nullptr;
    // Open the database file at the given path
    int rc = sqlite3_open(path.c_str(), &raw);
    m_db.reset(raw);
    if (rc != SQLITE_OK) {
        std::string err = sqlite3_errmsg(raw);
        m_db.reset();
        throw std::runtime_error("Failed to open database: " + err);
    }
}

void Database::execute(const std::string& sql) {
    char* errMsg = nullptr;
    // Execute a DDL or DML statement that returns no rows
    int rc = sqlite3_exec(m_db.get(), sql.c_str(), nullptr, nullptr, &errMsg);
    if (rc != SQLITE_OK) {
        std::string err = errMsg ? errMsg : "Unknown error";
        sqlite3_free(errMsg);
        throw std::runtime_error("SQL execution failed: " + err);
    }
}

Statement Database::prepare(const std::string& sql) {
    // Delegates to Statement constructor for compilation
    return Statement(m_db.get(), sql);
}

int64_t Database::lastInsertRowId() const {
    return sqlite3_last_insert_rowid(m_db.get());
}

What does this code do?

The constructor calls sqlite3_open and immediately hands the raw pointer to the unique_ptr. If the open fails, it still resets the pointer (triggering sqlite3_close on the partially-opened handle as the SQLite docs require) before throwing.

The execute() method wraps sqlite3_exec for fire-and-forget SQL (CREATE TABLE, INSERT without results). The prepare() method returns a Statement object for queries that need parameter binding or row iteration.

  • Save both Database.h and Database.cpp.

The build will fail right now because Database.cpp includes Statement.h which does not exist yet. You will create it next.

Create the Statement class with type-safe parameter binding

The Statement class wraps sqlite3_stmt* with the same RAII pattern. It provides overloaded bind() methods for each C++ type, plus a templated overload for std::optional<T> that binds NULL when the optional is empty.

  • Right-click **Header Files**, click **Add**, then **New Item**.
  • Select **Header File (.h)**, name it Statement.h, and click **Add**.
  • Paste the following header:
#pragma once
#include <string>
#include <memory>
#include <optional>
#include <stdexcept>
#include "sqlite/sqlite3.h"

class Statement {
public:
    Statement(sqlite3* db, const std::string& sql);
    ~Statement() = default;

    Statement(const Statement&) = delete;
    Statement& operator=(const Statement&) = delete;
    Statement(Statement&&) = default;
    Statement& operator=(Statement&&) = default;

    // Type-safe parameter binding overloads
    void bind(int index, int value);
    void bind(int index, int64_t value);
    void bind(int index, double value);
    void bind(int index, const std::string& value);
    void bind(int index, std::nullopt_t);

    // Binds NULL when optional is empty
    template<typename T>
    void bind(int index, const std::optional<T>& value) {
        if (value.has_value()) {
            bind(index, value.value());
        } else {
            bind(index, std::nullopt);
        }
    }

    bool step();
    void reset();

    // Column extraction by index
    int columnInt(int index) const;
    int64_t columnInt64(int index) const;
    double columnDouble(int index) const;
    std::string columnText(int index) const;

private:
    // Custom deleter calls sqlite3_finalize on destruction
    struct Deleter {
        void operator()(sqlite3_stmt* stmt) const {
            if (stmt) sqlite3_finalize(stmt);
        }
    };
    std::unique_ptr<sqlite3_stmt, Deleter> m_stmt;
};

Why overloads instead of a template?

Each SQLite bind function (sqlite3_bind_int, sqlite3_bind_double, sqlite3_bind_text) has a different signature. Explicit overloads map C++ types to the correct SQLite function at compile time with zero ambiguity.

The single template overload for std::optional<T> delegates to the correct non-template overload via value(), giving you nullable fields without manual null checks at every call site.

  • Right-click **Source Files**, click **Add**, then **New Item**.
  • Select **C++ File (.cpp)**, name it Statement.cpp, and click **Add**.
  • Paste the following implementation:
#include "pch.h"
#include "Statement.h"

Statement::Statement(sqlite3* db, const std::string& sql) {
    sqlite3_stmt* raw = nullptr;
    // Compile the SQL string into a prepared statement
    int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &raw, nullptr);
    m_stmt.reset(raw);
    if (rc != SQLITE_OK) {
        std::string err = sqlite3_errmsg(db);
        m_stmt.reset();
        throw std::runtime_error("Failed to prepare statement: " + err);
    }
}

void Statement::bind(int index, int value) {
    sqlite3_bind_int(m_stmt.get(), index, value);
}

void Statement::bind(int index, int64_t value) {
    sqlite3_bind_int64(m_stmt.get(), index, value);
}

void Statement::bind(int index, double value) {
    sqlite3_bind_double(m_stmt.get(), index, value);
}

void Statement::bind(int index, const std::string& value) {
    // SQLITE_TRANSIENT tells SQLite to copy the string data
    sqlite3_bind_text(m_stmt.get(), index, value.c_str(), -1, SQLITE_TRANSIENT);
}

void Statement::bind(int index, std::nullopt_t) {
    sqlite3_bind_null(m_stmt.get(), index);
}

bool Statement::step() {
    int rc = sqlite3_step(m_stmt.get());
    if (rc == SQLITE_ROW) return true;   // More rows available
    if (rc == SQLITE_DONE) return false;  // Statement finished
    throw std::runtime_error("Statement step failed: " + std::to_string(rc));
}

void Statement::reset() {
    // Rewind the statement for reuse with new bindings
    sqlite3_reset(m_stmt.get());
    sqlite3_clear_bindings(m_stmt.get());
}

int Statement::columnInt(int index) const {
    return sqlite3_column_int(m_stmt.get(), index);
}

int64_t Statement::columnInt64(int index) const {
    return sqlite3_column_int64(m_stmt.get(), index);
}

double Statement::columnDouble(int index) const {
    return sqlite3_column_double(m_stmt.get(), index);
}

std::string Statement::columnText(int index) const {
    const unsigned char* text = sqlite3_column_text(m_stmt.get(), index);
    return text ? std::string(reinterpret_cast<const char*>(text)) : std::string();
}

What does this code do?

The constructor calls sqlite3_prepare_v2 to compile a SQL string into a bytecode program. The step() method advances execution: it returns true when a row is available (SQLITE_ROW) and false when done (SQLITE_DONE).

Using SQLITE_TRANSIENT for text binds tells SQLite to make its own copy of the string data. This is essential because the std::string may go out of scope before the statement executes.

  • Save both Statement.h and Statement.cpp.
  • Build the project with **Ctrl+Shift+B**.

The build should succeed with zero errors. You now have a fully RAII-managed database layer.

Seeing linker errors about sqlite3 functions?

Make sure sqlite3.c is still included in your project (visible under **Source Files** in Solution Explorer) and that its **Precompiled Headers** property is set to **Not Using Precompiled Headers**.

If you see #include path errors, confirm the sqlite folder is directly inside your project directory (next to Database.h) and that the include reads "sqlite/sqlite3.h" with a forward slash.

help me fix SQLite linker or include errors in my MFC project

✔️ Awesome, I've got everything!

Great. Double check you have saved both file pairs before continuing.

ⓧ I'd like to double check the full code

Here is the complete Database.h:

#pragma once
#include <string>
#include <memory>
#include <stdexcept>
#include "sqlite/sqlite3.h"

class Statement;

class Database {
public:
    explicit Database(const std::string& path);
    ~Database() = default;

    Database(const Database&) = delete;
    Database& operator=(const Database&) = delete;
    Database(Database&&) = default;
    Database& operator=(Database&&) = default;

    void execute(const std::string& sql);
    Statement prepare(const std::string& sql);
    int64_t lastInsertRowId() const;

    sqlite3* raw() const { return m_db.get(); }

private:
    struct Deleter {
        void operator()(sqlite3* db) const {
            if (db) sqlite3_close(db);
        }
    };
    std::unique_ptr<sqlite3, Deleter> m_db;
};

Here is the complete Database.cpp:

#include "pch.h"
#include "Database.h"
#include "Statement.h"

Database::Database(const std::string& path) {
    sqlite3* raw = nullptr;
    int rc = sqlite3_open(path.c_str(), &raw);
    m_db.reset(raw);
    if (rc != SQLITE_OK) {
        std::string err = sqlite3_errmsg(raw);
        m_db.reset();
        throw std::runtime_error("Failed to open database: " + err);
    }
}

void Database::execute(const std::string& sql) {
    char* errMsg = nullptr;
    int rc = sqlite3_exec(m_db.get(), sql.c_str(), nullptr, nullptr, &errMsg);
    if (rc != SQLITE_OK) {
        std::string err = errMsg ? errMsg : "Unknown error";
        sqlite3_free(errMsg);
        throw std::runtime_error("SQL execution failed: " + err);
    }
}

Statement Database::prepare(const std::string& sql) {
    return Statement(m_db.get(), sql);
}

int64_t Database::lastInsertRowId() const {
    return sqlite3_last_insert_rowid(m_db.get());
}

Here is the complete Statement.h:

#pragma once
#include <string>
#include <memory>
#include <optional>
#include <stdexcept>
#include "sqlite/sqlite3.h"

class Statement {
public:
    Statement(sqlite3* db, const std::string& sql);
    ~Statement() = default;

    Statement(const Statement&) = delete;
    Statement& operator=(const Statement&) = delete;
    Statement(Statement&&) = default;
    Statement& operator=(Statement&&) = default;

    void bind(int index, int value);
    void bind(int index, int64_t value);
    void bind(int index, double value);
    void bind(int index, const std::string& value);
    void bind(int index, std::nullopt_t);

    template<typename T>
    void bind(int index, const std::optional<T>& value) {
        if (value.has_value()) {
            bind(index, value.value());
        } else {
            bind(index, std::nullopt);
        }
    }

    bool step();
    void reset();

    int columnInt(int index) const;
    int64_t columnInt64(int index) const;
    double columnDouble(int index) const;
    std::string columnText(int index) const;

private:
    struct Deleter {
        void operator()(sqlite3_stmt* stmt) const {
            if (stmt) sqlite3_finalize(stmt);
        }
    };
    std::unique_ptr<sqlite3_stmt, Deleter> m_stmt;
};

Here is the complete Statement.cpp:

#include "pch.h"
#include "Statement.h"

Statement::Statement(sqlite3* db, const std::string& sql) {
    sqlite3_stmt* raw = nullptr;
    int rc = sqlite3_prepare_v2(db, sql.c_str(), -1, &raw, nullptr);
    m_stmt.reset(raw);
    if (rc != SQLITE_OK) {
        std::string err = sqlite3_errmsg(db);
        m_stmt.reset();
        throw std::runtime_error("Failed to prepare statement: " + err);
    }
}

void Statement::bind(int index, int value) {
    sqlite3_bind_int(m_stmt.get(), index, value);
}

void Statement::bind(int index, int64_t value) {
    sqlite3_bind_int64(m_stmt.get(), index, value);
}

void Statement::bind(int index, double value) {
    sqlite3_bind_double(m_stmt.get(), index, value);
}

void Statement::bind(int index, const std::string& value) {
    sqlite3_bind_text(m_stmt.get(), index, value.c_str(), -1, SQLITE_TRANSIENT);
}

void Statement::bind(int index, std::nullopt_t) {
    sqlite3_bind_null(m_stmt.get(), index);
}

bool Statement::step() {
    int rc = sqlite3_step(m_stmt.get());
    if (rc == SQLITE_ROW) return true;
    if (rc == SQLITE_DONE) return false;
    throw std::runtime_error("Statement step failed: " + std::to_string(rc));
}

void Statement::reset() {
    sqlite3_reset(m_stmt.get());
    sqlite3_clear_bindings(m_stmt.get());
}

int Statement::columnInt(int index) const {
    return sqlite3_column_int(m_stmt.get(), index);
}

int64_t Statement::columnInt64(int index) const {
    return sqlite3_column_int64(m_stmt.get(), index);
}

double Statement::columnDouble(int index) const {
    return sqlite3_column_double(m_stmt.get(), index);
}

std::string Statement::columnText(int index) const {
    const unsigned char* text = sqlite3_column_text(m_stmt.get(), index);
    return text ? std::string(reinterpret_cast<const char*>(text)) : std::string();
}
Initialize the database schema

With the wrapper classes in place, you can now use them to create the Products table the first time the application starts. The MFC wizard generated an OnInitDialog function in InventoryManagerDlg.cpp where initialization code belongs.

  • Open InventoryManagerDlg.cpp from the **Source Files** folder in Solution Explorer.
  • Near the top of the file (after the existing includes), add these two lines:
#include "Database.h"
#include <string>
  • Scroll down to the CInventoryManagerDlg::OnInitDialog() function. Find the comment that reads:
// TODO: Add extra initialization here
  • Replace that comment with the following database initialization code:
// Create (or open) the SQLite database and ensure the Products table exists
try {
    Database db("inventory.db");
    db.execute(
        "CREATE TABLE IF NOT EXISTS Products ("
        "id INTEGER PRIMARY KEY AUTOINCREMENT, "
        "name TEXT NOT NULL, "
        "sku TEXT UNIQUE NOT NULL, "
        "category TEXT, "
        "quantity INTEGER DEFAULT 0, "
        "price REAL DEFAULT 0.0)");
} catch (const std::runtime_error& e) {
    AfxMessageBox(CString(e.what()));
}

What does this code do?

A Database object is constructed with the path "inventory.db". SQLite creates the file if it does not exist. The CREATE TABLE IF NOT EXISTS DDL is idempotent, so it runs safely on every startup without duplicating the table.

The try/catch block catches any database errors and displays them in a Windows message box rather than crashing the app. In later steps you will move the Database to a class member so it stays open for the lifetime of the dialog.

  • Save InventoryManagerDlg.cpp.
  • Run the application with **Ctrl+F5**.
  • Open your project's output directory (typically Debug or x64\Debug inside the solution folder). You should see a new file named inventory.db.

The dialog window appears with no crash and inventory.db now exists on disk. Your RAII wrapper is working end-to-end.

Don't see inventory.db?

Check the output directory. By default, Visual Studio places the executable (and any relative-path files it creates) in the build output folder, not the project source folder. Look inside the Debug or x64\Debug subdirectory of your solution.

If the app crashes on launch, check the **Output** window in Visual Studio for the exception message. A common cause is a mistyped include path for Database.h.

help me find where inventory.db is created or debug my OnInitDialog crash

Your database wrapper is built and verified. Next up, you will create a Product data model and a repository class that uses this wrapper to perform full CRUD operations.

Build the Product Repository and Data Model

Your RAII database wrapper handles the low-level connection and statement lifecycle. Now you need a layer on top that speaks in terms of products, not raw SQL strings and column indices.

In this step, you will define a Product struct as your data model and a ProductRepository class that provides typed CRUD operations. The repository uses the Database and Statement classes you already built, so calling code never touches SQLite directly.

In this step, get ready to:
  • Define the Product struct as a plain data model.
  • Create a ProductRepository with full CRUD methods.
  • Wire the repository into the main dialog and verify the connection end-to-end.
Define the Product struct

Every row in the Products table maps to a C++ struct. A simple value type with default-initialized fields keeps things predictable and easy to move around.

  • In Solution Explorer, right-click Header Files, then select Add > New Item.
  • Select Header File (.h), name it Product.h, and click Add.
  • Replace the contents of Product.h with this code:
#pragma once
#include <string>

struct Product {
    int id = 0;
    std::string name;
    std::string sku;
    std::string category;
    int quantity = 0;
    double price = 0.0;
};

What does this code do?

The Product struct is a plain old data type (POD). Each field maps to a column in the Products table.

Default member initializers (= 0, = 0.0) mean a freshly created Product is always in a safe state without needing a constructor.

  • Save Product.h.
Create the ProductRepository header

The repository pattern gives you a single class responsible for all database interactions related to products. Calling code asks for products by ID or gets a full list without knowing anything about SQL.

  • Right-click Header Files in Solution Explorer again.
  • Select Add > New Item.
  • Choose Header File (.h), name it ProductRepository.h, and click Add.
  • Replace the contents with this code:
#pragma once
#include <vector>
#include <optional>
#include "Product.h"
#include "Database.h"

class ProductRepository {
public:
    explicit ProductRepository(Database& db);

    std::vector<Product> getAll();
    std::optional<Product> getById(int id);
    int add(const Product& product);
    bool update(const Product& product);
    bool remove(int id);

private:
    Database& m_db;
};

What does this code do?

The constructor takes a Database& reference. The repository does not own the database connection. It just borrows it.

Return types communicate intent. getAll() returns a vector because there are always zero or more results. getById() returns std::optional<Product> because the ID might not exist. add() returns the new row's integer ID. update() and remove() return bool to indicate whether a row was actually affected.

  • Save ProductRepository.h.

Seeing red squiggles under std::optional?

Make sure your project is set to C++17. std::optional requires /std:c++17 or later. You configured this in Step 1. If IntelliSense still complains, close and reopen the file.

Still stuck? Why is std::optional not recognized in my MFC project?

Implement the read operations

Now you will implement the repository methods. The read operations demonstrate the core pattern: prepare a statement, bind parameters if needed, step through results, and extract columns into a Product struct.

  • Right-click Source Files in Solution Explorer.
  • Select Add > New Item.
  • Choose C++ File (.cpp), name it ProductRepository.cpp, and click Add.
  • Add the following code to implement the constructor, getAll(), and getById():
#include "pch.h"
#include "ProductRepository.h"

ProductRepository::ProductRepository(Database& db) : m_db(db) {}

std::vector<Product> ProductRepository::getAll() {
    std::vector<Product> products;
    auto stmt = m_db.prepare(
        "SELECT id, name, sku, category, quantity, price FROM Products ORDER BY name");

    while (stmt.step()) {
        Product p;
        p.id = stmt.columnInt(0);
        p.name = stmt.columnText(1);
        p.sku = stmt.columnText(2);
        p.category = stmt.columnText(3);
        p.quantity = stmt.columnInt(4);
        p.price = stmt.columnDouble(5);
        products.push_back(std::move(p));
    }
    return products;
}

std::optional<Product> ProductRepository::getById(int id) {
    auto stmt = m_db.prepare(
        "SELECT id, name, sku, category, quantity, price FROM Products WHERE id = ?");
    stmt.bind(1, id);

    if (stmt.step()) {
        Product p;
        p.id = stmt.columnInt(0);
        p.name = stmt.columnText(1);
        p.sku = stmt.columnText(2);
        p.category = stmt.columnText(3);
        p.quantity = stmt.columnInt(4);
        p.price = stmt.columnDouble(5);
        return p;
    }
    return std::nullopt;
}

What does this code do?

The constructor stores a reference to the Database instance. No copies, no ownership transfer.

In getAll(), stmt.step() returns true for each row (SQLITE_ROW) and false when the result set is exhausted (SQLITE_DONE). The while loop builds a vector one product at a time.

In getById(), the ? placeholder gets bound to the id parameter at index 1. SQLite parameter indices start at 1, not 0. If no row matches, the method returns std::nullopt.

  • Save ProductRepository.cpp.
  • Build the project with Ctrl+Shift+B. It should compile without errors.

Build errors mentioning pch.h?

Every .cpp file in an MFC project must include "pch.h" as its very first include. If you see a precompiled header error, make sure #include "pch.h" is the first line in ProductRepository.cpp.

Need more help? Why am I getting a precompiled header error in my new .cpp file?

Implement the write operations

The write operations follow the same prepare-bind-step pattern. The difference is they modify data and return confirmation of what changed.

  • In ProductRepository.cpp, add the following three methods below getById():
int ProductRepository::add(const Product& product) {
    auto stmt = m_db.prepare(
        "INSERT INTO Products (name, sku, category, quantity, price) VALUES (?, ?, ?, ?, ?)");
    stmt.bind(1, product.name);
    stmt.bind(2, product.sku);
    stmt.bind(3, product.category);
    stmt.bind(4, product.quantity);
    stmt.bind(5, product.price);
    stmt.step();
    return static_cast<int>(m_db.lastInsertRowId());
}

bool ProductRepository::update(const Product& product) {
    auto stmt = m_db.prepare(
        "UPDATE Products SET name = ?, sku = ?, category = ?, quantity = ?, price = ? WHERE id = ?");
    stmt.bind(1, product.name);
    stmt.bind(2, product.sku);
    stmt.bind(3, product.category);
    stmt.bind(4, product.quantity);
    stmt.bind(5, product.price);
    stmt.bind(6, product.id);
    stmt.step();
    return sqlite3_changes(m_db.raw()) > 0;
}

bool ProductRepository::remove(int id) {
    auto stmt = m_db.prepare("DELETE FROM Products WHERE id = ?");
    stmt.bind(1, id);
    stmt.step();
    return sqlite3_changes(m_db.raw()) > 0;
}

What does this code do?

Each write method binds parameters positionally. The ? placeholders in the SQL correspond to bind indices 1 through N.

In add(), after stepping the INSERT statement, m_db.lastInsertRowId() retrieves the auto-generated primary key for the new row.

In update() and remove(), sqlite3_changes(m_db.raw()) returns the number of rows modified by the most recent statement. If it returns 0, the target row did not exist.

  • Save ProductRepository.cpp.
  • Build with Ctrl+Shift+B. Confirm a clean compile with no errors.

Unresolved external symbol for sqlite3_changes?

The sqlite3_changes function lives in sqlite3.c. If you see a linker error, confirm that sqlite3.c is still part of the project under Source Files in Solution Explorer. Also confirm its precompiled header setting is still set to Not Using Precompiled Headers.

Still stuck? Help me fix an unresolved external symbol error for a SQLite function in my MFC project.

✔️ Awesome, I've got everything!

Great. Make sure you have saved ProductRepository.cpp before continuing.

ⓧ I'd like to double check the full code

#include "pch.h"
#include "ProductRepository.h"

ProductRepository::ProductRepository(Database& db) : m_db(db) {}

std::vector<Product> ProductRepository::getAll() {
    std::vector<Product> products;
    auto stmt = m_db.prepare(
        "SELECT id, name, sku, category, quantity, price FROM Products ORDER BY name");

    while (stmt.step()) {
        Product p;
        p.id = stmt.columnInt(0);
        p.name = stmt.columnText(1);
        p.sku = stmt.columnText(2);
        p.category = stmt.columnText(3);
        p.quantity = stmt.columnInt(4);
        p.price = stmt.columnDouble(5);
        products.push_back(std::move(p));
    }
    return products;
}

std::optional<Product> ProductRepository::getById(int id) {
    auto stmt = m_db.prepare(
        "SELECT id, name, sku, category, quantity, price FROM Products WHERE id = ?");
    stmt.bind(1, id);

    if (stmt.step()) {
        Product p;
        p.id = stmt.columnInt(0);
        p.name = stmt.columnText(1);
        p.sku = stmt.columnText(2);
        p.category = stmt.columnText(3);
        p.quantity = stmt.columnInt(4);
        p.price = stmt.columnDouble(5);
        return p;
    }
    return std::nullopt;
}

int ProductRepository::add(const Product& product) {
    auto stmt = m_db.prepare(
        "INSERT INTO Products (name, sku, category, quantity, price) VALUES (?, ?, ?, ?, ?)");
    stmt.bind(1, product.name);
    stmt.bind(2, product.sku);
    stmt.bind(3, product.category);
    stmt.bind(4, product.quantity);
    stmt.bind(5, product.price);
    stmt.step();
    return static_cast<int>(m_db.lastInsertRowId());
}

bool ProductRepository::update(const Product& product) {
    auto stmt = m_db.prepare(
        "UPDATE Products SET name = ?, sku = ?, category = ?, quantity = ?, price = ? WHERE id = ?");
    stmt.bind(1, product.name);
    stmt.bind(2, product.sku);
    stmt.bind(3, product.category);
    stmt.bind(4, product.quantity);
    stmt.bind(5, product.price);
    stmt.bind(6, product.id);
    stmt.step();
    return sqlite3_changes(m_db.raw()) > 0;
}

bool ProductRepository::remove(int id) {
    auto stmt = m_db.prepare("DELETE FROM Products WHERE id = ?");
    stmt.bind(1, id);
    stmt.step();
    return sqlite3_changes(m_db.raw()) > 0;
}
Wire the repository into the main dialog

The repository class is ready, but nothing in your application uses it yet. You need to create instances of both Database and ProductRepository in the main dialog so they live for the lifetime of the window.

  • Open InventoryManagerDlg.h from Solution Explorer.
  • Add the following includes near the top of the file, below the existing includes:
#include <memory>
#include "Database.h"
#include "ProductRepository.h"
  • Inside the CInventoryManagerDlg class declaration, add these two private members:
// Database and repository lifetime tied to the dialog
std::unique_ptr<Database> m_db;
std::unique_ptr<ProductRepository> m_repo;

Why std::unique_ptr for these members?

Using std::unique_ptr lets you defer construction to OnInitDialog (where you know the path is ready) while still getting automatic cleanup when the dialog is destroyed. No manual delete needed.

  • Now open InventoryManagerDlg.cpp.
  • Find your OnInitDialog() method. Locate the section where you created the database and executed the CREATE TABLE statement.
  • Replace that database initialization code with the following, which creates both the database and the repository, then verifies the connection:
// Initialize database and repository
m_db = std::make_unique<Database>("inventory.db");
m_db->execute(
    "CREATE TABLE IF NOT EXISTS Products ("
    "id INTEGER PRIMARY KEY AUTOINCREMENT, "
    "name TEXT NOT NULL, "
    "sku TEXT UNIQUE NOT NULL, "
    "category TEXT, "
    "quantity INTEGER DEFAULT 0, "
    "price REAL DEFAULT 0.0)");

m_repo = std::make_unique<ProductRepository>(*m_db);

// Verify connection by querying the product count
auto products = m_repo->getAll();
TRACE(_T("Products: %d\n"), static_cast<int>(products.size()));

What does this code do?

First, m_db is constructed with the database file path. The CREATE TABLE DDL runs to ensure the schema exists.

Then m_repo is constructed with a reference to the database (note the dereference *m_db to get a Database& from the unique_ptr).

Finally, TRACE outputs the product count to the Visual Studio Output window. This confirms the full pipeline works: database opens, schema exists, repository queries successfully.

  • Save both InventoryManagerDlg.h and InventoryManagerDlg.cpp.
  • Build and run with Ctrl+F5.
  • Check the Output window in Visual Studio (View > Output if not visible). You should see Products: 0 in the debug output.

Don't see Products: 0 in the output?

Make sure you ran with Ctrl+F5 (Start Without Debugging) or F5 (Start Debugging). TRACE only writes to the Output window during a debug session or when the debug runtime is active.

If the app crashes on startup, check that m_db is created before m_repo. The repository takes a reference to the database, so the database must exist first.

Still stuck? Help me debug why my MFC app crashes in OnInitDialog when initializing the database and repository.

Your data layer is fully wired. The app can create, read, update, and delete products through typed methods that handle all the SQL internally. Next up, you will build the product list UI so users can actually see their inventory on screen.

Build the Product List UI with CMFCListCtrl

Your data layer is complete. The database wrapper, statement handler, and product repository are all wired together and ready to serve data.

The problem is that your users still see the default MFC dialog with a "TODO" label. In this step, you will replace that placeholder with a professional product grid that displays sortable columns backed by your repository.

In this step, get ready to:
  • Replace the default dialog controls with a list control and action buttons.
  • Populate the list from the repository with a RefreshList() method.
  • Enable column sorting with a custom CMFCListCtrl subclass.
Replace the default dialog content with CMFCListCtrl

MFC's resource editor lets you design dialog layouts visually. You will remove the wizard-generated placeholder controls and drop in a List Control configured for report view.

  • In Solution Explorer, expand Resource Files and double-click InventoryManager.rc.
  • In the resource tree, expand Dialog and double-click IDD_INVENTORYMANAGER_DIALOG to open it in the resource editor.
  • Select the static text control that says "TODO: Place dialog controls here.", then press Delete on your keyboard.
  • Select and delete the OK button.
  • Select and delete the Cancel button.

Now you have an empty dialog canvas ready for the product list.

  • Open the Toolbox panel (press Ctrl+Alt+X if it is not visible).
  • Drag a List Control from the Toolbox onto the dialog surface.
  • Resize it to fill most of the dialog, leaving room at the bottom for buttons.
  • With the list control selected, open the Properties window (press F4).
  • Set the View property to Report.
  • Note the control's ID (e.g. IDC_LIST1). You will use this when adding the member variable.
  • Drag three Button controls from the Toolbox below the list control.
  • Set their captions (in Properties > Caption) to Add, Edit, and Delete.
  • Set their IDs to IDC_BTN_ADD, IDC_BTN_EDIT, and IDC_BTN_DELETE.
  • Save the resource file with Ctrl+S.

What is report view?

A List Control in Report view displays data in a table format with column headers. Each row represents one record and each column shows one field. This is the standard layout for any data grid in Windows desktop applications.

Next, add a member variable so your C++ code can interact with the list control.

  • Right-click the list control in the resource editor.
  • Select Add Variable.
  • In the dialog that opens, set Variable type to CMFCListCtrl.
  • Set Variable name to m_listCtrl.
  • Click Finish.

Why CMFCListCtrl instead of CListCtrl?

CMFCListCtrl extends CListCtrl with built-in sort arrows on column headers and multi-column sort support via an OnCompareItems override. You get a professional-looking grid with minimal extra code.

Now configure the list columns and extended styles in OnInitDialog. This runs once when the dialog opens.

  • Open InventoryManagerDlg.cpp and find the OnInitDialog() method.
  • Add the following column configuration after the existing database and repository initialization code:
// Configure list control columns
m_listCtrl.InsertColumn(0, _T("Name"), LVCFMT_LEFT, 150);
m_listCtrl.InsertColumn(1, _T("SKU"), LVCFMT_LEFT, 100);
m_listCtrl.InsertColumn(2, _T("Category"), LVCFMT_LEFT, 100);
m_listCtrl.InsertColumn(3, _T("Quantity"), LVCFMT_LEFT, 80);
m_listCtrl.InsertColumn(4, _T("Price"), LVCFMT_LEFT, 80);

// Enable full-row selection and grid lines
m_listCtrl.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES);

What does this code do?

InsertColumn adds each column header to the report view. The parameters are: column index, header text, format (left-aligned), and pixel width.

LVS_EX_FULLROWSELECT highlights the entire row when clicked instead of just the first column. LVS_EX_GRIDLINES draws cell borders so the list looks like a proper table.

  • Save InventoryManagerDlg.cpp.
  • Build and run the project with Ctrl+F5. You should see a dialog with an empty grid showing five column headers (Name, SKU, Category, Quantity, Price) and three buttons below it.

List control appears blank with no columns?

Make sure you set the View property to Report in the resource editor. Without report view, InsertColumn calls are ignored.

Also confirm that the member variable m_listCtrl is mapped to the correct control ID in DoDataExchange. The wizard adds a DDX_Control line that ties the control to the variable.

Why isn't my list control showing column headers?

Populate the list from the repository

The list control shows column headers but no data. You need a method that queries the repository and fills the grid with product rows.

  • Open InventoryManagerDlg.h and add the following method declaration inside the CInventoryManagerDlg class (in a private or public section):
void RefreshList();
  • Implement RefreshList() in InventoryManagerDlg.cpp by adding this method:
void CInventoryManagerDlg::RefreshList() {
    // Clear all existing rows
    m_listCtrl.DeleteAllItems();

    // Fetch all products from the database
    auto products = m_repo->getAll();

    // Insert each product as a row in the list
    for (int i = 0; i < static_cast<int>(products.size()); ++i) {
        const auto& p = products[i];

        // InsertItem adds the row and sets the first column text
        int nItem = m_listCtrl.InsertItem(i, CString(p.name.c_str()));

        // SetItemText fills the remaining columns
        m_listCtrl.SetItemText(nItem, 1, CString(p.sku.c_str()));
        m_listCtrl.SetItemText(nItem, 2, CString(p.category.c_str()));

        CString strQty;
        strQty.Format(_T("%d"), p.quantity);
        m_listCtrl.SetItemText(nItem, 3, strQty);

        CString strPrice;
        strPrice.Format(_T("%.2f"), p.price);
        m_listCtrl.SetItemText(nItem, 4, strPrice);

        // Store the product ID so we can retrieve it on selection
        m_listCtrl.SetItemData(nItem, static_cast<DWORD_PTR>(p.id));
    }
}

What does this code do?

DeleteAllItems() wipes the grid clean so you can rebuild it from scratch. This avoids duplicates when the list is refreshed after an add or delete operation.

InsertItem adds a new row and sets column 0 (Name). SetItemText fills columns 1 through 4. SetItemData attaches the product's database ID to the row so you can look it up later without parsing visible text.

  • Call RefreshList() at the end of OnInitDialog(), after the column configuration code you added earlier:
// Load product data into the list
RefreshList();
  • Save both InventoryManagerDlg.h and InventoryManagerDlg.cpp.
  • Build and run with Ctrl+F5. The list still appears empty because the Products table has no rows yet. The important thing is that it builds and runs without crashing.

Build error on RefreshList?

If you get an undeclared identifier error for m_repo, make sure the #include "ProductRepository.h" line is present in your dialog header or source file.

If CString conversion fails, confirm that pch.h is included as the first line of your .cpp file.

Help me fix a build error in my RefreshList implementation.

Enable column sorting

CMFCListCtrl handles sort arrows and header clicks automatically. You just need to override OnCompareItems() to tell it HOW to compare two rows for a given column.

To do this, you will create a small subclass called CProductListCtrl that customizes the sort behavior for numeric and text columns.

  • Right-click the project in Solution Explorer, select Add > New Item.
  • Choose Header File (.h) and name it CProductListCtrl.h.
  • Paste the following class definition:
#pragma once
#include <afxlistctrl.h>

class CProductListCtrl : public CMFCListCtrl {
public:
    int GetSortColumn() const { return m_iSortColumn; }
    bool IsAscending() const { return m_bAscending; }

    void SetSortState(int column, bool ascending) {
        m_iSortColumn = column;
        m_bAscending = ascending;
    }

protected:
    virtual int OnCompareItems(LPARAM lParam1, LPARAM lParam2, int iColumn) override;

private:
    int m_iSortColumn = 0;
    bool m_bAscending = true;
};

What does this code do?

CProductListCtrl inherits from CMFCListCtrl and overrides OnCompareItems. When the user clicks a column header, CMFCListCtrl calls this method for every pair of rows that need comparing during the sort.

The helper methods (GetSortColumn, IsAscending, SetSortState) track which column is sorted and in which direction.

  • Add another new item: a C++ File (.cpp) named CProductListCtrl.cpp.
  • Implement the comparison logic:
#include "pch.h"
#include "CProductListCtrl.h"

int CProductListCtrl::OnCompareItems(LPARAM lParam1, LPARAM lParam2, int iColumn) {
    int nItem1 = -1;
    int nItem2 = -1;

    for (int i = 0; i < GetItemCount(); ++i) {
        LPARAM data = GetItemData(i);
        if (data == lParam1) nItem1 = i;
        if (data == lParam2) nItem2 = i;
    }

    if (nItem1 < 0 || nItem2 < 0) return 0;

    CString str1 = GetItemText(nItem1, iColumn);
    CString str2 = GetItemText(nItem2, iColumn);

    // Numeric comparison for Quantity (col 3) and Price (col 4)
    if (iColumn == 3) {
        int val1 = _ttoi(str1);
        int val2 = _ttoi(str2);
        return val1 - val2;
    }
    if (iColumn == 4) {
        double val1 = _ttof(str1);
        double val2 = _ttof(str2);
        if (val1 < val2) return -1;
        if (val1 > val2) return 1;
        return 0;
    }

    // Case-insensitive text comparison
    return str1.CompareNoCase(str2);
}

What does this code do?

OnCompareItems receives two LPARAM values that correspond to item data (the product IDs you stored via SetItemData). It first finds which visible row indices belong to those IDs, then retrieves their text for the clicked column.

Columns 3 and 4 (Quantity and Price) are parsed as numbers using _ttoi and _ttof so that "12" sorts before "100" instead of after (as it would in a text sort). All other columns use case-insensitive text comparison via CompareNoCase.

Now update the main dialog to use your custom class instead of the base CMFCListCtrl.

  • Open InventoryManagerDlg.h.
  • Add #include "CProductListCtrl.h" near the top of the file.
  • Find the member variable declaration for the list control. It currently reads:
CMFCListCtrl m_listCtrl;
  • Change the type from CMFCListCtrl to CProductListCtrl, so the line now reads:
CProductListCtrl m_listCtrl;
  • Save all modified files (Ctrl+Shift+S).
  • Build and run with Ctrl+F5. You should see the empty list with column headers. Click any column header. The sort arrow should appear and toggle direction on repeated clicks. No crash.

Linker error about OnCompareItems?

If you see an unresolved external symbol for CProductListCtrl::OnCompareItems, confirm that CProductListCtrl.cpp is included in your project (visible in Solution Explorer under Source Files).

If the sort arrow appears but rows do not reorder (once you have data), make sure the SetItemData call in RefreshList() is setting a non-zero product ID. OnCompareItems uses those IDs to locate rows.

Help me fix a linker error in my CProductListCtrl class.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for both new files. Compare yours against these:

#pragma once
#include <afxlistctrl.h>

class CProductListCtrl : public CMFCListCtrl {
public:
    int GetSortColumn() const { return m_iSortColumn; }
    bool IsAscending() const { return m_bAscending; }

    void SetSortState(int column, bool ascending) {
        m_iSortColumn = column;
        m_bAscending = ascending;
    }

protected:
    virtual int OnCompareItems(LPARAM lParam1, LPARAM lParam2, int iColumn) override;

private:
    int m_iSortColumn = 0;
    bool m_bAscending = true;
};
#include "pch.h"
#include "CProductListCtrl.h"

int CProductListCtrl::OnCompareItems(LPARAM lParam1, LPARAM lParam2, int iColumn) {
    int nItem1 = -1;
    int nItem2 = -1;

    for (int i = 0; i < GetItemCount(); ++i) {
        LPARAM data = GetItemData(i);
        if (data == lParam1) nItem1 = i;
        if (data == lParam2) nItem2 = i;
    }

    if (nItem1 < 0 || nItem2 < 0) return 0;

    CString str1 = GetItemText(nItem1, iColumn);
    CString str2 = GetItemText(nItem2, iColumn);

    // Numeric comparison for Quantity (col 3) and Price (col 4)
    if (iColumn == 3) {
        int val1 = _ttoi(str1);
        int val2 = _ttoi(str2);
        return val1 - val2;
    }
    if (iColumn == 4) {
        double val1 = _ttof(str1);
        double val2 = _ttof(str2);
        if (val1 < val2) return -1;
        if (val1 > val2) return 1;
        return 0;
    }

    // Case-insensitive text comparison
    return str1.CompareNoCase(str2);
}

Your product grid is ready to display and sort data. Next up, you will wire the Add, Edit, and Delete buttons so users can manage inventory through a dialog form.

Implement Add, Edit, and Delete Product Operations

Your product list shows columns and supports sorting, but there is no way to populate it with real data yet.

In this step you will build the full CRUD user experience: a form dialog for adding and editing products, plus a confirmation-based delete flow. By the end, users can create products, modify them, and remove them with all changes persisted to SQLite.

In this step, get ready to:
  • Create the Add/Edit Product dialog with input validation.
  • Implement Add and Edit button handlers that persist data.
  • Implement Delete with a confirmation prompt.
Create the Add/Edit Product dialog resource

Every CRUD application needs a form for user input. In MFC, forms are implemented as dialog resources with associated C++ classes. You will create a single dialog that serves double duty for both adding new products and editing existing ones.

  • Open Resource View by going to View > Other Windows > Resource View.
  • In Resource View, right-click the Dialog folder under your .rc file.
  • Select Add Resource > Dialog > New.
  • A new blank dialog opens in the Dialog Editor with a default ID like IDD_DIALOG1.
  • In the Properties window, change the ID to IDD_PRODUCT_DIALOG.
  • Set the Caption to Product.

Now add five Edit Control fields and their labels using the Toolbox:

  • Drag a Static Text control onto the dialog and set its caption to Name:. Drag an Edit Control next to it and set its ID to IDC_EDIT_NAME.
  • Add another Static Text/Edit Control pair for SKU. Set the edit control ID to IDC_EDIT_SKU.
  • Add a third pair for Category. Set the edit control ID to IDC_EDIT_CATEGORY.
  • Add a fourth pair for Quantity. Set the edit control ID to IDC_EDIT_QUANTITY.
  • Add a fifth pair for Price. Set the edit control ID to IDC_EDIT_PRICE.
  • Keep the default OK and Cancel buttons that the editor placed automatically.

What does each control ID do?

Each IDC_EDIT_* identifier connects a visual edit control in the dialog to a C++ member variable through MFC's Dialog Data Exchange (DDX) system. You will wire these up in the DoDataExchange method shortly.

  • Save the resource file by pressing Ctrl+S.

Don't see the Dialog Editor Toolbox?

Make sure the dialog tab is the active document in the editor frame. The Dialog Editor tab only appears in the Toolbox when the dialog is focused.

help me find the Dialog Editor Toolbox in Visual Studio

Create the CProductDialog class

The dialog resource is just a visual layout. You need a C++ class to manage data exchange between the controls and your code. This class will handle validation too, so users cannot enter negative quantities or leave required fields blank.

  • Create a new header file CProductDialog.h in your project by right-clicking the project in Solution Explorer, selecting Add > New Item, and choosing Header File (.h).
  • Paste the following class declaration into CProductDialog.h:
#pragma once
#include "Product.h"
#include "resource.h"

class CProductDialog : public CDialogEx {
    DECLARE_DYNAMIC(CProductDialog)

public:
    CProductDialog(CWnd* pParent = nullptr);

    // Transfer data between Product struct and dialog fields
    void SetProduct(const Product& product);
    Product GetProduct() const;

    enum { IDD = IDD_PRODUCT_DIALOG };

protected:
    virtual void DoDataExchange(CDataExchange* pDX) override;
    virtual BOOL OnInitDialog() override;

    DECLARE_MESSAGE_MAP()

private:
    CString m_strName;
    CString m_strSKU;
    CString m_strCategory;
    int m_nQuantity = 0;
    double m_dPrice = 0.0;
    int m_nProductId = 0;
};

What does this class do?

The enum { IDD = IDD_PRODUCT_DIALOG } constant links this class to the dialog resource you just created. MFC uses it to load the correct layout when you call DoModal().

The private members (m_strName, m_nQuantity, etc.) hold form values. SetProduct loads a Product into these members for editing. GetProduct reads them back out as a Product struct.

  • Create CProductDialog.cpp the same way (Add > New Item > C++ File (.cpp)).
  • Add the data conversion methods that bridge between MFC CString and your std::string-based Product struct by pasting this code:
#include "pch.h"
#include "CProductDialog.h"

IMPLEMENT_DYNAMIC(CProductDialog, CDialogEx)

CProductDialog::CProductDialog(CWnd* pParent)
    : CDialogEx(IDD_PRODUCT_DIALOG, pParent) {}

void CProductDialog::SetProduct(const Product& product) {
    m_nProductId = product.id;
    m_strName = CString(product.name.c_str());
    m_strSKU = CString(product.sku.c_str());
    m_strCategory = CString(product.category.c_str());
    m_nQuantity = product.quantity;
    m_dPrice = product.price;
}

Product CProductDialog::GetProduct() const {
    Product p;
    p.id = m_nProductId;
    p.name = CT2A(m_strName);
    p.sku = CT2A(m_strSKU);
    p.category = CT2A(m_strCategory);
    p.quantity = m_nQuantity;
    p.price = m_dPrice;
    return p;
}

Why CT2A for string conversion?

MFC uses CString (which maps to wide strings in Unicode builds). Your Product struct uses std::string (narrow/UTF-8). The CT2A macro converts from the TCHAR type to a narrow char pointer safely.

Next, add the DDX/DDV validation logic that MFC calls automatically when the user clicks OK.

  • Below the GetProduct method in CProductDialog.cpp, add the DoDataExchange override:
void CProductDialog::DoDataExchange(CDataExchange* pDX) {
    CDialogEx::DoDataExchange(pDX);
    DDX_Text(pDX, IDC_EDIT_NAME, m_strName);
    DDV_MaxChars(pDX, m_strName, 255);
    DDX_Text(pDX, IDC_EDIT_SKU, m_strSKU);
    DDV_MaxChars(pDX, m_strSKU, 50);
    DDX_Text(pDX, IDC_EDIT_CATEGORY, m_strCategory);
    DDX_Text(pDX, IDC_EDIT_QUANTITY, m_nQuantity);
    DDV_MinMaxInt(pDX, m_nQuantity, 0, 999999);
    DDX_Text(pDX, IDC_EDIT_PRICE, m_dPrice);
    DDV_MinMaxDouble(pDX, m_dPrice, 0.0, 9999999.99);
}

What is DoDataExchange?

MFC's Dialog Data Exchange system calls DoDataExchange in two directions. When the dialog opens, it copies member variable values into the controls. When the user clicks OK, it reads the controls back into the variables and runs validation.

Each DDX_Text call binds a control ID to a member variable. Each DDV_* call enforces a constraint. If validation fails, MFC shows an error message and returns focus to the offending control automatically.

  • Finally, add OnInitDialog and the message map at the bottom of CProductDialog.cpp:
BOOL CProductDialog::OnInitDialog() {
    CDialogEx::OnInitDialog();

    if (m_nProductId > 0) {
        SetWindowText(_T("Edit Product"));
    } else {
        SetWindowText(_T("Add Product"));
    }

    return TRUE;
}

BEGIN_MESSAGE_MAP(CProductDialog, CDialogEx)
END_MESSAGE_MAP()

Why change the title dynamically?

When m_nProductId is greater than zero, the dialog is editing an existing product. Otherwise it is creating a new one. Changing the window text gives users a clear visual signal about which operation they are performing.

  • Save both CProductDialog.h and CProductDialog.cpp.
  • Build the project (Ctrl+Shift+B). It should compile without errors.

Seeing undeclared identifier errors for IDC_EDIT_NAME?

Make sure you included resource.h in CProductDialog.h and that the control IDs in your dialog resource match exactly (IDC_EDIT_NAME, IDC_EDIT_SKU, etc.). Open resource.h and verify the #define entries exist for each control ID.

help me fix undeclared identifier errors for my dialog control IDs

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Here is the complete CProductDialog.h:

#pragma once
#include "Product.h"
#include "resource.h"

class CProductDialog : public CDialogEx {
    DECLARE_DYNAMIC(CProductDialog)

public:
    CProductDialog(CWnd* pParent = nullptr);

    void SetProduct(const Product& product);
    Product GetProduct() const;

    enum { IDD = IDD_PRODUCT_DIALOG };

protected:
    virtual void DoDataExchange(CDataExchange* pDX) override;
    virtual BOOL OnInitDialog() override;

    DECLARE_MESSAGE_MAP()

private:
    CString m_strName;
    CString m_strSKU;
    CString m_strCategory;
    int m_nQuantity = 0;
    double m_dPrice = 0.0;
    int m_nProductId = 0;
};

And the complete CProductDialog.cpp:

#include "pch.h"
#include "CProductDialog.h"

IMPLEMENT_DYNAMIC(CProductDialog, CDialogEx)

CProductDialog::CProductDialog(CWnd* pParent)
    : CDialogEx(IDD_PRODUCT_DIALOG, pParent) {}

void CProductDialog::SetProduct(const Product& product) {
    m_nProductId = product.id;
    m_strName = CString(product.name.c_str());
    m_strSKU = CString(product.sku.c_str());
    m_strCategory = CString(product.category.c_str());
    m_nQuantity = product.quantity;
    m_dPrice = product.price;
}

Product CProductDialog::GetProduct() const {
    Product p;
    p.id = m_nProductId;
    p.name = CT2A(m_strName);
    p.sku = CT2A(m_strSKU);
    p.category = CT2A(m_strCategory);
    p.quantity = m_nQuantity;
    p.price = m_dPrice;
    return p;
}

void CProductDialog::DoDataExchange(CDataExchange* pDX) {
    CDialogEx::DoDataExchange(pDX);
    DDX_Text(pDX, IDC_EDIT_NAME, m_strName);
    DDV_MaxChars(pDX, m_strName, 255);
    DDX_Text(pDX, IDC_EDIT_SKU, m_strSKU);
    DDV_MaxChars(pDX, m_strSKU, 50);
    DDX_Text(pDX, IDC_EDIT_CATEGORY, m_strCategory);
    DDX_Text(pDX, IDC_EDIT_QUANTITY, m_nQuantity);
    DDV_MinMaxInt(pDX, m_nQuantity, 0, 999999);
    DDX_Text(pDX, IDC_EDIT_PRICE, m_dPrice);
    DDV_MinMaxDouble(pDX, m_dPrice, 0.0, 9999999.99);
}

BOOL CProductDialog::OnInitDialog() {
    CDialogEx::OnInitDialog();

    if (m_nProductId > 0) {
        SetWindowText(_T("Edit Product"));
    } else {
        SetWindowText(_T("Add Product"));
    }

    return TRUE;
}

BEGIN_MESSAGE_MAP(CProductDialog, CDialogEx)
END_MESSAGE_MAP()
Implement Add and Edit button handlers

The dialog class is ready. Now you need to connect the Add and Edit buttons on your main dialog to open CProductDialog and persist the result.

  • In CInventoryManagerDlg.cpp, add the include at the top of the file:
#include "CProductDialog.h"
  • Add an ON_BN_CLICKED handler for the Add button. You can add this by double-clicking the Add button in the Dialog Editor, or by adding the handler manually in the message map and implementation.
  • Implement the Add handler with this logic:
void CInventoryManagerDlg::OnBnClickedButtonAdd() {
    CProductDialog dlg(this);
    if (dlg.DoModal() == IDOK) {
        Product p = dlg.GetProduct();
        m_repo->add(p);
        RefreshList();
    }
}

What does this handler do?

It creates a CProductDialog and shows it modally. If the user fills in the form and clicks OK (triggering DDX validation), DoModal() returns IDOK. The handler then extracts the Product struct and passes it to the repository's add() method, which inserts it into SQLite.

  • Implement the Edit handler. This one needs to load the selected product before opening the dialog:
void CInventoryManagerDlg::OnBnClickedButtonEdit() {
    // Get selected item index
    int nSel = m_listCtrl.GetNextItem(-1, LVNI_SELECTED);
    if (nSel < 0) return;

    // Retrieve the product ID stored as item data
    int productId = static_cast<int>(m_listCtrl.GetItemData(nSel));
    auto existing = m_repo->getById(productId);
    if (!existing.has_value()) return;

    // Open dialog pre-populated with existing values
    CProductDialog dlg(this);
    dlg.SetProduct(existing.value());
    if (dlg.DoModal() == IDOK) {
        Product updated = dlg.GetProduct();
        m_repo->update(updated);
        RefreshList();
    }
}

How does the Edit handler find the right product?

Back in Step 4, RefreshList() stored each product's database ID as item data using SetItemData. The Edit handler reads it back with GetItemData, fetches the full record from the repository, and loads it into the dialog with SetProduct.

  • Add both handlers to the BEGIN_MESSAGE_MAP block in CInventoryManagerDlg.cpp:
ON_BN_CLICKED(IDC_BUTTON_ADD, &CInventoryManagerDlg::OnBnClickedButtonAdd)
ON_BN_CLICKED(IDC_BUTTON_EDIT, &CInventoryManagerDlg::OnBnClickedButtonEdit)
  • Also declare both methods in CInventoryManagerDlg.h:
afx_msg void OnBnClickedButtonAdd();
afx_msg void OnBnClickedButtonEdit();
  • Save all files and build (Ctrl+Shift+B).

Seeing LNK2019 unresolved external for OnBnClickedButtonAdd?

Make sure the method declaration in the header matches the implementation signature exactly. Also check that you added the ON_BN_CLICKED entries inside the BEGIN_MESSAGE_MAP/END_MESSAGE_MAP block, and that the button IDs (IDC_BUTTON_ADD, IDC_BUTTON_EDIT) match what you set in the dialog resource editor.

help me fix my ON_BN_CLICKED linker error

Implement Delete with confirmation

Delete operations are destructive. A confirmation prompt protects users from accidental data loss.

  • Add the Delete handler to CInventoryManagerDlg.cpp:
void CInventoryManagerDlg::OnBnClickedButtonDelete() {
    // Check if an item is selected
    int nSel = m_listCtrl.GetNextItem(-1, LVNI_SELECTED);
    if (nSel < 0) return;

    // Get product name for the confirmation message
    CString productName = m_listCtrl.GetItemText(nSel, 0);
    CString msg;
    msg.Format(_T("Are you sure you want to delete %s?"), productName.GetString());

    // Show confirmation and act on Yes
    if (MessageBox(msg, _T("Confirm Delete"), MB_YESNO | MB_ICONQUESTION) == IDYES) {
        int productId = static_cast<int>(m_listCtrl.GetItemData(nSel));
        m_repo->remove(productId);
        RefreshList();
    }
}

What does this handler do?

It reads the selected product's name from column 0 of the list control and formats it into a human-readable confirmation message. Only if the user clicks Yes does the handler call m_repo->remove() to delete the row from SQLite.

  • Add the message map entry for Delete:
ON_BN_CLICKED(IDC_BUTTON_DELETE, &CInventoryManagerDlg::OnBnClickedButtonDelete)
  • Declare the method in CInventoryManagerDlg.h:
afx_msg void OnBnClickedButtonDelete();
  • Save all files and build the project.
  • Run the application (Ctrl+F5).
  • Click Add and create 3-4 products with different names, SKUs, categories, quantities, and prices.
  • Select a product and click Edit. Change its price and click OK. Verify the updated value appears in the list.
  • Select a product and click Delete. Confirm the removal. The product disappears from the list.
  • Close the application and reopen it. Your products persist from the SQLite database.

Products not persisting after restart?

Check where inventory.db is being created. If you are running from Visual Studio, the working directory is usually the project folder, not the output folder. Verify the path you pass to the Database constructor matches where you expect the file to live.

help me find where my SQLite database file is being saved

Your inventory manager now has a complete CRUD workflow. Next up, you will push this further with an Undo/Redo system built on the Command pattern.

Secret mission

Implement Undo/Redo with the Command Pattern

Your inventory manager works great, but what happens when someone accidentally deletes a product? Professional applications let users undo their mistakes. In this mission, you'll implement a full undo/redo system using the Command pattern with polymorphic command objects and std::stack.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files to continue building, or delete them entirely.

Resources you used:

  • InventoryManager Visual Studio solution folder (contains all source code, SQLite amalgamation, and generated inventory.db).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend this project with the next parts of the series.

  • Your InventoryManager project stays in Visual Studio's recent projects list for quick access.
  • The inventory.db file persists your product data between sessions.

Pause - I'll come back to this later

Close Visual Studio to free up memory. Your project files stay on disk and you can reopen the solution any time.

  • Close Visual Studio.
  • To resume later, double-click the InventoryManager.sln file in your project folder to reopen everything.

Delete - I don't want to use this again

Remove the entire project folder to start fresh. This deletes all source code, the SQLite amalgamation files, and the database.

  • Close Visual Studio if it is still open.
  • Open File Explorer and navigate to the folder where Visual Studio created your InventoryManager project. This is typically inside your source\repos folder under your user profile.
  • Delete the entire InventoryManager folder.
  • Confirm the folder is gone by checking the parent directory.

Nice Work!

Nice Work!

Nice work! You just built a professional Windows desktop application from scratch using MFC and Modern C++.

You've learned how to:

  • Built a professional MFC dialog-based application with a sortable product grid using CMFCListCtrl and validated Add/Edit/Delete product forms.
  • Created a reusable RAII SQLite wrapper from scratch using std::unique_ptr custom deleters and type-safe parameter binding.
  • Implemented a typed ProductRepository with full CRUD operations and persistent storage across application sessions.
  • Secret Mission: Implemented a full Undo/Redo system using the Command pattern with polymorphic command objects and std::stack.

Ready to quiz yourself?