Object-Oriented Programming: Classes and Objects
🎯 What You'll Learn
- What object-oriented programming (OOP) is and why it matters
- The difference between classes and objects
- How to define classes with fields, properties, and methods
- Constructors and object initialization
- Access modifiers (public, private, protected, internal)
- Static members vs instance members
- The four pillars of OOP: Encapsulation, Inheritance, Polymorphism, Abstraction
- Properties vs fields and why properties are preferred
- Building real InvenTrack classes for our mini-ERP system
What is Object-Oriented Programming?
So far, we've been writing procedural code—variables and methods that operate on data. This works for small programs, but as applications grow, you need a better way to organize complexity. That's where Object-Oriented Programming (OOP) comes in.
Object-Oriented Programming is a programming paradigm that organizes code around objects—bundles of data (properties) and behavior (methods) that represent real-world entities. Instead of scattered variables and functions, you group related data and operations into cohesive units called classes.
Why Use OOP?
- Organization: Group related data and behavior together
- Reusability: Create templates (classes) and reuse them throughout your application
- Maintainability: Changes to a class affect all objects created from it
- Abstraction: Hide complexity behind simple interfaces
- Real-world modeling: Code mirrors the problem domain (customers, products, orders)
Classes and Objects: The Fundamentals
What is a Class?
A class is a blueprint or template that defines the structure and behavior of objects. It specifies what data an object will hold (fields/properties) and what operations it can perform (methods).
What is an Object?
An object is a specific instance of a class—an actual entity created from the blueprint. Each object has its own copy of the data defined by the class.
Your First Class
// Define a class
class Product
{
// Fields (data)
public string Name;
public decimal Price;
public int Quantity;
// Method (behavior)
public decimal GetTotalValue()
{
return Price * Quantity;
}
}
// Create objects (instances)
Product laptop = new Product();
laptop.Name = "Dell XPS 15";
laptop.Price = 1299.99m;
laptop.Quantity = 5;
Product mouse = new Product();
mouse.Name = "Logitech MX Master";
mouse.Price = 99.99m;
mouse.Quantity = 20;
Console.WriteLine($"Laptop inventory value: {laptop.GetTotalValue():C}");
Console.WriteLine($"Mouse inventory value: {mouse.GetTotalValue():C}");
Class = Blueprint/Template (defined once)
Object = Instance (created many times with new)
One Product class can create thousands of product objects, each with different
data.
Constructors: Initializing Objects
A constructor is a special method that runs when you create an object. It's used to initialize the object's data. Constructors have the same name as the class and no return type.
class Product
{
public string Name;
public decimal Price;
public int Quantity;
// Constructor
public Product(string name, decimal price, int quantity)
{
Name = name;
Price = price;
Quantity = quantity;
}
public decimal GetTotalValue() => Price * Quantity;
}
// Much cleaner object creation!
Product laptop = new Product("Dell XPS 15", 1299.99m, 5);
Product mouse = new Product("Logitech MX Master", 99.99m, 20);
Constructor Overloading
class Product
{
public string Name;
public decimal Price;
public int Quantity;
// Default constructor
public Product()
{
Name = "Unknown";
Price = 0m;
Quantity = 0;
}
// Constructor with parameters
public Product(string name, decimal price)
{
Name = name;
Price = price;
Quantity = 0;
}
// Full constructor
public Product(string name, decimal price, int quantity)
{
Name = name;
Price = price;
Quantity = quantity;
}
}
Product p1 = new Product();
Product p2 = new Product("Widget", 19.99m);
Product p3 = new Product("Gadget", 29.99m, 100);
Access Modifiers: Controlling Visibility
Access modifiers control who can see and use your class members. This is crucial for encapsulation—hiding internal details and exposing only what's necessary.
| Modifier | Visibility | When to Use |
|---|---|---|
public |
Accessible from anywhere | Public API of your class |
private |
Only within the same class | Internal implementation details |
protected |
Same class and derived classes | Shared with child classes |
internal |
Same assembly (project) | Internal to your library |
protected internal |
Same assembly OR derived classes | Combination of both |
class BankAccount
{
private decimal balance; // Hidden from outside
public BankAccount(decimal initialBalance)
{
balance = initialBalance;
}
public void Deposit(decimal amount)
{
if (amount > 0)
balance += amount;
}
public bool Withdraw(decimal amount)
{
if (amount > 0 && amount <= balance)
{
balance -= amount;
return true;
}
return false;
}
public decimal GetBalance() => balance;
}
BankAccount account = new BankAccount(1000m);
account.Deposit(500m);
// account.balance = 5000000; // ERROR! Can't access private field
Console.WriteLine(account.GetBalance()); // OK - public method
Make fields private by default. Expose data through public
properties or methods. This gives you control over how data is accessed and modified.
Properties: The Right Way to Expose Data
While you can use public fields, C# provides a better way: properties. Properties look like fields from the outside but act like methods internally, giving you control over getting and setting values.
Auto-Implemented Properties
class Product
{
// Auto-implemented properties (compiler creates hidden field)
public string Name { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
public Product(string name, decimal price, int quantity)
{
Name = name;
Price = price;
Quantity = quantity;
}
}
Product p = new Product("Widget", 19.99m, 100);
Console.WriteLine(p.Name); // Uses get accessor
p.Price = 24.99m; // Uses set accessor
Properties with Validation Logic
class Product
{
private string name;
private decimal price;
private int quantity;
public string Name
{
get => name;
set
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Name cannot be empty");
name = value;
}
}
public decimal Price
{
get => price;
set
{
if (value < 0)
throw new ArgumentException("Price cannot be negative");
price = value;
}
}
public int Quantity
{
get => quantity;
set
{
if (value < 0)
throw new ArgumentException("Quantity cannot be negative");
quantity = value;
}
}
}
Read-Only and Init-Only Properties
class Product
{
// Read-only property (can only be set in constructor)
public string Sku { get; }
// Init-only property (can be set during object initialization)
public string Name { get; init; }
public decimal Price { get; set; }
// Computed property (no setter, calculated on-the-fly)
public decimal PriceWithTax => Price * 1.125m;
public Product(string sku)
{
Sku = sku; // Can set in constructor
}
}
Product p = new Product("SKU-001")
{
Name = "Widget", // OK - init property
Price = 100m
};
// p.Name = "Gadget"; // ERROR! Can't set init property after initialization
// p.Sku = "SKU-002"; // ERROR! No setter
p.Price = 120m; // OK - has setter
Static Members: Shared Across All Instances
By default, each object has its own copy of fields and properties. But sometimes you want
data or behavior that's shared across all instances of a class. That's where
static comes in.
class Product
{
// Static field - shared by all Product instances
private static int nextId = 1;
public static decimal TaxRate = 0.125m;
// Instance fields - each object has its own copy
public int Id { get; }
public string Name { get; set; }
public decimal Price { get; set; }
public Product(string name, decimal price)
{
Id = nextId++; // Auto-increment ID
Name = name;
Price = price;
}
// Static method - called on the class, not an instance
public static decimal CalculateTax(decimal amount)
{
return amount * TaxRate;
}
// Instance method
public decimal GetPriceWithTax()
{
return Price * (1 + TaxRate);
}
}
Product p1 = new Product("Widget", 100m);
Product p2 = new Product("Gadget", 200m);
Console.WriteLine(p1.Id); // 1
Console.WriteLine(p2.Id); // 2
// Static method - called on class
decimal tax = Product.CalculateTax(100m);
// Change static field - affects all instances
Product.TaxRate = 0.15m;
Console.WriteLine(p1.GetPriceWithTax()); // Uses new tax rate
Console.WriteLine(p2.GetPriceWithTax()); // Uses new tax rate
| Instance Members | Static Members |
|---|---|
| Each object has its own copy | Shared across all objects |
Accessed via object reference (p1.Name) |
Accessed via class name (Product.TaxRate) |
| Can access instance and static members | Can only access static members |
| Requires object creation | No object needed |
Use static for utility methods (Math.Sqrt), shared configuration,
counters, or anything that doesn't depend on instance data. Examples:
Console.WriteLine,
int.Parse, DateTime.Now.
The Four Pillars of OOP
Object-Oriented Programming is built on four fundamental principles. Understanding these will help you design better, more maintainable software.
1. Encapsulation: Hiding Implementation Details
Encapsulation means bundling data and methods together while hiding internal details. You expose a clean public interface and keep implementation private.
class InventoryItem
{
private int quantity; // Hidden
private int reorderLevel = 10;
public string ProductName { get; set; }
// Controlled access through properties
public int Quantity
{
get => quantity;
set
{
if (value < 0)
throw new ArgumentException("Quantity cannot be negative");
quantity = value;
CheckReorderStatus();
}
}
public bool NeedsReorder { get; private set; }
// Private helper method
private void CheckReorderStatus()
{
NeedsReorder = quantity < reorderLevel;
}
// Public methods for controlled operations
public void AddStock(int amount)
{
if (amount > 0)
Quantity += amount;
}
public bool RemoveStock(int amount)
{
if (amount > 0 && amount <= quantity)
{
Quantity -= amount;
return true;
}
return false;
}
}
Control: Validate data before accepting it
Flexibility: Change internal implementation without breaking external code
Security: Prevent direct manipulation of sensitive data
Maintainability: Clear separation between interface and implementation
2. Inheritance: Building on Existing Classes
Inheritance allows you to create new classes based on existing ones, reusing and extending their functionality. The new class (child/derived) inherits members from the base class (parent).
// Base class
class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public string Sku { get; set; }
public virtual decimal CalculateTotal(int quantity)
{
return Price * quantity;
}
public virtual void DisplayInfo()
{
Console.WriteLine($"Product: {Name}, Price: {Price:C}");
}
}
// Derived class - inherits from Product
class DigitalProduct : Product
{
public string DownloadUrl { get; set; }
public int FileSizeMB { get; set; }
// Override base method
public override void DisplayInfo()
{
base.DisplayInfo(); // Call parent method
Console.WriteLine($"Download: {DownloadUrl}, Size: {FileSizeMB}MB");
}
}
// Another derived class
class PhysicalProduct : Product
{
public decimal Weight { get; set; }
public decimal ShippingCost { get; set; }
public override decimal CalculateTotal(int quantity)
{
return (Price * quantity) + ShippingCost;
}
}
DigitalProduct ebook = new DigitalProduct
{
Name = "C# Programming Guide",
Price = 29.99m,
DownloadUrl = "https://example.com/ebook.pdf",
FileSizeMB = 15
};
PhysicalProduct book = new PhysicalProduct
{
Name = "C# in Depth",
Price = 49.99m,
Weight = 1.2m,
ShippingCost = 5.99m
};
ebook.DisplayInfo();
Console.WriteLine(book.CalculateTotal(2)); // Includes shipping
| Keyword | Purpose |
|---|---|
virtual |
Marks a method/property in base class as overridable |
override |
Provides new implementation in derived class |
base |
Calls the parent class's version of a method |
sealed |
Prevents further inheritance |
3. Polymorphism: Many Forms
Polymorphism means "many forms." It allows objects of different types to be treated uniformly through a common interface, while each type provides its own specific behavior.
// Polymorphism in action - same interface, different behaviors
Product[] products = new Product[]
{
new DigitalProduct { Name = "Software", Price = 99.99m },
new PhysicalProduct { Name = "Book", Price = 29.99m, ShippingCost = 5m },
new Product { Name = "Generic", Price = 19.99m }
};
// Each calls its own version of CalculateTotal
foreach (Product p in products)
{
Console.WriteLine($"{p.Name}: {p.CalculateTotal(1):C}");
p.DisplayInfo();
}
4. Abstraction: Simplifying Complexity
Abstraction means hiding complex implementation details and showing only the essential features. You define what something does, not how it does it.
// Abstract class - cannot be instantiated directly
abstract class PaymentMethod
{
public string TransactionId { get; set; }
// Abstract method - must be implemented by derived classes
public abstract bool ProcessPayment(decimal amount);
// Concrete method - shared by all payment methods
public void LogTransaction(decimal amount)
{
Console.WriteLine($"Transaction {TransactionId}: {amount:C}");
}
}
class CreditCardPayment : PaymentMethod
{
public string CardNumber { get; set; }
public override bool ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing credit card payment: {amount:C}");
// Credit card processing logic here
LogTransaction(amount);
return true;
}
}
class PayPalPayment : PaymentMethod
{
public string Email { get; set; }
public override bool ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing PayPal payment: {amount:C}");
// PayPal processing logic here
LogTransaction(amount);
return true;
}
}
// Usage - we don't care HOW payment is processed
PaymentMethod payment = new CreditCardPayment
{
TransactionId = "TXN-001",
CardNumber = "****1234"
};
payment.ProcessPayment(99.99m);
Abstract: No implementation in base class, derived classes MUST implement
Virtual: Has implementation in base class, derived classes CAN override
Building InvenTrack: Practical OOP Example
Let's apply everything we've learned to build core classes for our InvenTrack mini-ERP system.
// Product class with full OOP principles
class Product
{
private static int nextId = 1;
private decimal price;
private int quantityInStock;
// Properties
public int Id { get; }
public string Sku { get; init; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price
{
get => price;
set
{
if (value < 0)
throw new ArgumentException("Price cannot be negative");
price = value;
}
}
public int QuantityInStock
{
get => quantityInStock;
private set => quantityInStock = value;
}
public int ReorderLevel { get; set; } = 10;
public bool IsLowStock => quantityInStock < ReorderLevel;
// Constructor
public Product(string sku, string name, decimal price, int initialStock)
{
Id = nextId++;
Sku = sku;
Name = name;
Price = price;
QuantityInStock = initialStock;
}
// Methods
public void AddStock(int quantity)
{
if (quantity <= 0)
throw new ArgumentException("Quantity must be positive");
QuantityInStock += quantity;
}
public bool RemoveStock(int quantity)
{
if (quantity <= 0 || quantity > QuantityInStock)
return false;
QuantityInStock -= quantity;
return true;
}
public decimal GetInventoryValue() => Price * QuantityInStock;
}
// Customer class
class Customer
{
private static int nextId = 1000;
public int Id { get; }
public string Name { get; set; }
public string Email { get; set; }
public string Phone { get; set; }
public decimal CreditLimit { get; set; }
public decimal CurrentBalance { get; private set; }
public Customer(string name, string email)
{
Id = nextId++;
Name = name;
Email = email;
CreditLimit = 1000m;
}
public bool CanPurchase(decimal amount)
{
return (CurrentBalance + amount) <= CreditLimit;
}
public void AddCharge(decimal amount)
{
CurrentBalance += amount;
}
public void MakePayment(decimal amount)
{
if (amount > CurrentBalance)
amount = CurrentBalance;
CurrentBalance -= amount;
}
}
// SalesOrder class
class SalesOrder
{
private static int nextOrderNumber = 1;
public int OrderNumber { get; }
public Customer Customer { get; }
public DateTime OrderDate { get; }
public List<OrderLine> Lines { get; }
public decimal Total => Lines.Sum(l => l.GetLineTotal());
public SalesOrder(Customer customer)
{
OrderNumber = nextOrderNumber++;
Customer = customer;
OrderDate = DateTime.Now;
Lines = new List<OrderLine>();
}
public void AddLine(Product product, int quantity)
{
Lines.Add(new OrderLine(product, quantity));
}
}
class OrderLine
{
public Product Product { get; }
public int Quantity { get; set; }
public decimal UnitPrice { get; }
public OrderLine(Product product, int quantity)
{
Product = product;
Quantity = quantity;
UnitPrice = product.Price; // Lock in price at order time
}
public decimal GetLineTotal() => UnitPrice * Quantity;
}
// Usage example
Product laptop = new Product("LAP-001", "Dell XPS 15", 1299.99m, 10);
Product mouse = new Product("MOU-001", "Logitech MX", 99.99m, 50);
Customer customer = new Customer("Akwasi Asante", "akwasi@example.com");
SalesOrder order = new SalesOrder(customer);
order.AddLine(laptop, 1);
order.AddLine(mouse, 2);
Console.WriteLine($"Order #{{order.OrderNumber}} for {customer.Name}");
Console.WriteLine($"Total: {order.Total:C}");
Notice how these classes model real business entities. Each class has a single, clear responsibility. Data is protected with private fields and exposed through properties. Methods provide controlled operations. This is professional, production-quality code structure!
Key Takeaways
- Classes are blueprints; objects are instances created from those blueprints
- Constructors initialize objects when they're created
- Access modifiers (public, private, protected, internal) control visibility
- Properties are preferred over public fields—they provide encapsulation and validation
- Static members are shared across all instances; instance members are unique per object
- Encapsulation: Bundle data and methods, hide implementation details
- Inheritance: Build new classes based on existing ones (use
virtual/override) - Polymorphism: Treat different types uniformly through a common interface
- Abstraction: Hide complexity, define contracts with abstract classes/methods
- OOP helps you model real-world domains in code, making applications more maintainable and scalable
You now understand the fundamentals of object-oriented programming! In the next section, we'll explore Collections and Generics—how to work with groups of objects efficiently and type-safely. You'll learn about Lists, Dictionaries, and how to write reusable code that works with any type.