Understanding the CSLA Transaction Attribute

Understanding the CSLA Transaction Attribute

Old forum URL: forums.lhotka.net/forums/t/3434.aspx


dmccrory posted on Tuesday, August 28, 2007

I am trying to understand the CSLA Transaction Attribute and would like to ask the more experienced developers here for some help. I have a base object DataPortal_Insert( ) function that is handling the insertion of an object into the database. It is a one to one relationship between the object and the database table.

The method is decorated with the Transactional(TransactionalTypes.TransactionScope)], even though it is probably not needed.

Secondly, I have other objects that inherit from the base object that are "owned" by other objects. These objects override the DataPortal_Insert() method. The overridden method calls the base.DataPortal_Insert() method first to insert the object, then makes another database call to insert the record in the "link" table. This method is also decorated with the same attribute and needs to be handled as a transaction. I want to rollback the database if either table fails to update. With just the attribute in place, the database updates the first table even if the second one fails, but if I wrap the code in a using TransactionScope statement as shown below, the transaction works as expected. I have read back through the book looking for reasons why the attribute does not work as expected, but with my limited knowledge in this area, I do not understand why. I would appreciate any light that can be shed on this subject and advice that can be given on the "correct" or "standard" way to accomplish this.

 

[Transactional(TransactionalTypes.TransactionScope)]

protected override void DataPortal_Insert()

{

   using (TransactionScope ts = new TransactionScope())

   {

      base.DataPortal_Insert();

      using (SqlCommand cmd = SQLStoredProcedure.New(DataConnection.ConnectionString).Command)

      {

         cmd.CommandText = "InsertEmployeeOwner";

         cmd.Parameters.AddWithValue("@ownerID", _ownerID);

         cmd.Parameters.AddWithValue("@employeeID", ObjectID);

         IAsyncResult result = cmd.BeginExecuteNonQuery();

         cmd.EndExecuteNonQuery(result);

      }

      ts.Complete();

   }

}

 

Thanks in advance....David

 

ajj3085 replied on Tuesday, August 28, 2007

Well, in your code you don't need the attribute because you're handling transactions yourself.  If you keep the attribute, you can remove the using ( ts = ... ) and ts.Complete() statemetns, because the dataportal will handle the transaction scope for you.

On other comment; there doesn't seem to be a reason to use BeginExecute and EndExecute; you'll block just the same as if you used the normal ExecuteNonQuery.

dmccrory replied on Wednesday, August 29, 2007

That is the point...if I remove the using (ts = ) and the ts.Complete() and just use the attribute, the code does not process in a transaction.

On your other comment, the code is just test code that I am using to try out different scenarios. At one point I was working with asyncronous calls and just left the code as is. But I appreciate the advice.

ajj3085 replied on Wednesday, August 29, 2007

dmccrory:
That is the point...if I remove the using (ts = ) and the ts.Complete() and just use the attribute, the code does not process in a transaction.


I think I understand your original post now.  What exactly is your design for these objects?  You have a base class, with subclasses, and these subclasses are child objects of some othe root object?  If that's the case, child objects shouldn't override DP_I, and the attribute should be on the DP_I of the root object only. 

If the subclasses are root objects, then I believe the attribute needs to be on the subclasses DP_I, because that is the one that the dataportal will examine via reflection.  It wouldn't do anything if applied to the base class' DP_I.

dmccrory replied on Friday, August 31, 2007

I have some very generic objects such as Address, Email and Telephone. These objects can belong to any one of a number of other objects such as an Employee, a Client or a Vendor. The database is designed to allow the 2 tables (i.e. Address and Employee) to be linked via a separate table. The idea was to only have one address table and use in anytime I had the need for an address and link it to the owner of the address regardless who that owner was. I didn't want to repeat fields like street, city and state in every table such as CleintAddress, EmployeeAddress and VendorAddress.

The Address and Client objects are derived from the same Base class because the only difference between the 2 objects is the fact that the Address object has an owner and the Client object does not. The base object takes care of the CRUD function for the base object and the object that has an "owner" has an overriden DataPortal_Insert() method that calls the base.DataPortal_Insert() to populate the main table, then has addditional code to populate the "owner" or link table.

There is probably a "better" or "more intuitive" way to do this, but with my limited experience, it is the solution that I am attempting to make work.

RockfordLhotka replied on Saturday, September 01, 2007

I strongly recommend against using inheritance to reuse fields. Inheritance should be used to reuse methods only. That's a design best practice overall, because inheritance is about reuse of behavior, not reuse of data.

It sounds like you should have two classes: Address and Client. And they should both inherit from BusinessBase<T> directly. They clearly have different responsibilities in your use cases and so should be separate types.

It is fine to have Address be an editable child that can be a child of several parents. There should be no problem with that, as long as you are consistent with how you manage transactions across all your root objects.

But Client is very clearly something different from Address. It sounds like it is an editable root, and that it plays a different role in your use cases from storing address information for some other object. If it has a different responsibility, then it should be its own object.

That'll make your life much simpler.

Remember that reuse isn't the goal. Maintainability is the goal. Too much reuse decreases maintainability because it increases coupling and thus complexity.

dmccrory replied on Saturday, September 01, 2007

Rocky,

I created a DatabaseObject<T> that inherits directly from BusinessBase<T> and contains all of the audit type data fields that are included in every table for these types of objects such as EnteredBy, EnteredDateTime and LastChanged. I also created a “DataHelper” object that uses custom attributes on the object’s properties to “fill” the object’s custom fields or the stored procedure parameters as needed. I did this to try to apply the DRY principle to my code because, after reviewing the initial code, the only differences in the code were the stored procedure name and the custom fields for each object.

 

This is an area of great frustration to me. I am a self taught programming enthusiast. I have read countless books on every topic imaginable and have tried to understand and apply the principles the author is trying to convey. It is not what I do for a living, but it is definitely what motivates me. It is my hobby and my passion and I aspire to get to a level to allow me to develop “rich” applications applying the “best practices” available. I consider your (Rocky) book one of the best learning tools I have read. From Chapter one, I just felt like I “got it”. While some of the details and examples are over my knowledge level, I felt like I understood “why” you were doing what you were doing. But when trying to apply other principles and practices that I have read, I don’t quite understand which should be applied and which should not. I always think that there has to be a reason why some of the other principles are not applied, but I struggle understanding when to and when NOT do apply certain principles or practices.

 

I went straight to work in the railroad industry right out of high school so I do not have the college education that most do in this field have, but I have worked myself into an “analyst” type position working for a software company that writes software for the railroad industry. Our software is written for the iSeries platform with a case tool called Synon. We have a small .NET initiative, but most of the code written in Visual Basic in a “procedural” type style with very little object oriented principles applied so I don’t have the luxury of finding someone that can guide me in my current work environment.

 

What I long for is a “mentor” type person that can review my code and direct me to the correct or “best practice” way to accomplish my task. I am not looking for someone to write the code for me and I am willing to do the research and work needed, I just need someone to help me understand the when and where of it all. I am not looking for something for nothing and do not have a monetary need so if I were to find someone willing to teach or coach me to be a better programmer, I would be willing to trade development time for free. I think I have a good enough understanding to be able to write some of the simple type coding that has to be done every day by someone.

 

I apologize for the long winded reply and appreciate anyone who has taken the time to read this far. I truly appreciate the amount of work and time required to develop the talent and skill the people in this forum display on a daily basis and I am at “awe” sometimes at how the answers to, what I consider, very technical questions are handled with ease providing much needed insight to all.

 

Thanks,

-David

 

dmccrory replied on Sunday, September 09, 2007

Ok...I took the forum's advice and decided to move all of the fields into each object and not inherit them as I was doing before. Now back to the original question. My DataPortal_Insert() method (see below) is not handled as a transaction with the [Transactional(TransactionalTypes.TransactionScope)] tag, but if I use using (TransactionScope ts = new TransactionScope()), it works as expected. Can someone please explain the difference?

This does not work....I intentionally created an error for the second table....the first table is updated and the second is not.

[Transactional(TransactionalTypes.TransactionScope)]

protected override void DataPortal_Insert()

{

try

{

using (SqlCommand cmd = SQLStoredProcedure.New(DataConnection.ConnectionString).Command)

{

cmd.CommandText = "InsertEmployee";

SqlParameter parm = new SqlParameter("@newID", SqlDbType.UniqueIdentifier);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

AssignObjectParameters(cmd);

cmd.Parameters.AddWithValue("@enteredBy", Csla.ApplicationContext.User.Identity.Name);

cmd.Parameters.AddWithValue("@enteredDateTime", DateTime.Now);

parm = new SqlParameter("@newLastChanged", SqlDbType.Timestamp);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

cmd.ExecuteNonQuery();

_auditInfo.LastChanged = (byte[])cmd.Parameters["@newLastChanged"].Value;

EmployeeID = (Guid)cmd.Parameters["@newID"].Value;

cmd.CommandText = "InsertEmployeeOwner";

cmd.Parameters.Clear();

cmd.Parameters.AddWithValue("@ownerID", _ownerID);

cmd.Parameters.AddWithValue("@employeeID", EmployeeID);

cmd.ExecuteNonQuery();

UpdateObjectLists();

}

}

catch (SqlException exc)

{

throw new Exception(exc.Message);

}

}

This works....with the intentional error, neither table is updated as expected.

protected override void DataPortal_Insert()

{

try

{

using (TransactionScope ts = new TransactionScope())

{

using (SqlCommand cmd = SQLStoredProcedure.New(DataConnection.ConnectionString).Command)

{

cmd.CommandText = "InsertEmployee";

SqlParameter parm = new SqlParameter("@newID", SqlDbType.UniqueIdentifier);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

AssignObjectParameters(cmd);

cmd.Parameters.AddWithValue("@enteredBy", Csla.ApplicationContext.User.Identity.Name);

cmd.Parameters.AddWithValue("@enteredDateTime", DateTime.Now);

parm = new SqlParameter("@newLastChanged", SqlDbType.Timestamp);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

cmd.ExecuteNonQuery();

_auditInfo.LastChanged = (byte[])cmd.Parameters["@newLastChanged"].Value;

EmployeeID = (Guid)cmd.Parameters["@newID"].Value;

cmd.CommandText = "InsertEmployeeOwner2";

cmd.Parameters.Clear();

cmd.Parameters.AddWithValue("@ownerID", _ownerID);

cmd.Parameters.AddWithValue("@employeeID", EmployeeID);

cmd.ExecuteNonQuery();

UpdateObjectLists();

}

ts.Complete();

}

}

catch (SqlException exc)

{

throw new Exception(exc.Message);

}

}

RockfordLhotka replied on Monday, September 10, 2007

I am not familiar with the SqlStoredProcedure type. My only guess is you are using a DAL framework, and perhaps it is somehow messing with the way CSLA uses TransactionScope.

Though even that seems unlikely, given that all CSLA does is wrap the call to DP_I inside a using block just like the one you are creating by hand.

Are you sure the exception is flowing up from your DP_I method? CSLA relies on your exception to trigger the rollback.

You could step through the code in the debugger (if you have a version of Csla.dll built for debug) and see where the code goes as it flows back into the data portal.

dmccrory replied on Monday, September 10, 2007

The SqlStoredProcedure isn't really doing anything other than returning a SqlCommand.

I will do some more work to see if the exception is flowing correctly.

Thanks,

dmccrory replied on Monday, September 10, 2007

Could this be because I am throwning a System.Exception in my catch block? Should I be throwing a different type of exception? I tried throwing a DataPortalException, but got the same results...

-David

RockfordLhotka replied on Monday, September 10, 2007

System.Exception should be fine.

But where are you opening the db connection? Since that wasn't in your code, I figured it was being opened in that SqlStoredProcedure object or something?

I believe that the connection must be opened after you are inside the TransactionScope, so it gets enlisted in the transaction properly (though I don't know that for a fact).

But that's my guess, is that somehow the connection isn't being opened at the right time or in the right way or something such that it doesn't get enlisted in the transaction created by the TransactionScope.

dmccrory replied on Monday, September 10, 2007

Yes I am opening the db connection and returning the SqlCommand from that object.

If I am using the TransactionScope attribute, I was assuming that the entire method would be inside the TransactionScope.

I will eliminate the SqlStoredProcedure object and see if the problem is corrected.

Thanks,

David

dmccrory replied on Monday, September 10, 2007

I have changed the DataPortal_Insert() method to contain all of the code without any outside calls. However the results are the same.....the Insert method is not handled as a transaction.

I will continue to investigating, but I would appreciate any information if someone has had the same problem, or sees something wrong with the code.

[Transactional(TransactionalTypes.TransactionScope)]

protected override void DataPortal_Insert()

{

try

{

SqlConnection con = new SqlConnection(DataConnection.ConnectionString);

con.Open();

SqlCommand cmd = con.CreateCommand();

cmd.CommandType = CommandType.StoredProcedure;

cmd.CommandText = "InsertEmployee";

SqlParameter parm = new SqlParameter("@newID", SqlDbType.UniqueIdentifier);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

FillObjectParameters(cmd);

cmd.Parameters.AddWithValue("@enteredBy", Csla.ApplicationContext.User.Identity.Name);

cmd.Parameters.AddWithValue("@enteredDateTime", DateTime.Now);

parm = new SqlParameter("@newLastChanged", SqlDbType.Timestamp);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

cmd.ExecuteNonQuery();

_auditInfo.LastChanged = (byte[])cmd.Parameters["@newLastChanged"].Value;

EmployeeID = (Guid)cmd.Parameters["@newID"].Value;

cmd.CommandText = "InsertEmployeeOwner2";

cmd.Parameters.Clear();

cmd.Parameters.AddWithValue("@ownerID", _ownerID);

cmd.Parameters.AddWithValue("@employeeID", EmployeeID);

cmd.ExecuteNonQuery();

}

catch (SqlException exc)

{

throw new Exception(exc.Message);

}

}

ajj3085 replied on Tuesday, September 11, 2007

That looks like it should be fine..  do you get an exception?

Probably unrelated, but I'd remove your try catch block completely.  You're actually losing exception detail with the way you're handling the exception.. no stack trace or anything.  If you are getting an exception, you're losing valuable information as to what the exception is (beyond the message itself).

dmccrory replied on Tuesday, September 11, 2007

Thanks for taking a look and for the suggestion. I have removed the Try and Catch blocks as suggested, but the results are the same.

I know what the exception is because I am purposely generating it to test the transaction. The first stored procedure is inserting the record properly and the second stored procedure (which has an incorrect Stored Procedure name) generates an exception.

If I use a using (TransactionScope ts = new TransactionScope()) block with a call to ts.Complete(); the code runs as a transaction and the first table insert is rolled back when the second table insert fails.

If I use the [Transactional(TransactionalTypes.TransactionScope)]  attribute the first table insert does not rollback when the second table insert fails.

ajj3085 replied on Wednesday, September 12, 2007

Have you tried stepping in to the DataPortal.Insert method.  This should let you know which dataportal is being used (Transactional, Simple, etc.).  You can also see if the code is correctly finding the attribute.

I suspect something in your code that we haven't seen, because I use this attribute exclusively for transactions, and it works as expected.

dean replied on Wednesday, September 12, 2007

I suspect that an event somewhere is being run right after your code and messing something up. So the event is either in CSLA or your code. Can you alter the two classes being tested : strip out/comment all the variables and properties except the GetIDValue override, strip out all the validation and authorization overrides, and don't assign anything in your data access code that is being tested. At this pont you pretty much eliminate any possiblity of it being your code. Then we can try this on some other machines and see if we can reproduce it.

Dean

dmccrory replied on Thursday, September 13, 2007

Ok. I think I will create a stripped down object to test with. If it passes, I will start adding things back to try to isolate the problem.

Thanks for your time. I will let you know the results.

-David

dmccrory replied on Monday, September 17, 2007

I have stripped everything out of my object that is not needed and I am still experiencing the same problem so I beginning to think the problem is not related to my code. I have an employee object that inherits directly from BusinessBase<T>. The DataPortal_Insert() method does not make any outside calls other than a static call to get the connection string. The code for the insert event is posted below.

Just to restate the problem so you don't have to read the entire thread.

The DataPortal_Insert() method is not being handled as a transaction when using the  [Transactional(TransactionalTypes.TransactionScope)] attribute, but if I use a using (TransactionScope ts = new TransactionScope())  block the transaction is rolled back properly. I am purposely creating an exception by setting the second CommandText property to an invalid stored procedure name.

Could someone please try to reproduce this and let me know if they experience the same problem? I am using Visual Studio 2008 Beta 2 Professional and CSLA 3.0.1

Thanks,

-David

This code is not handled as a transaction. The first table is updated even though the second table fails.

[Transactional(TransactionalTypes.TransactionScope)]

protected override void DataPortal_Insert()

{

SqlConnection con = new SqlConnection(DataConnection.ConnectionString);

con.Open();

SqlCommand cmd = con.CreateCommand();

cmd.CommandType = CommandType.StoredProcedure;

cmd.CommandText = "InsertEmployee";

cmd.Parameters.AddWithValue("@firstName", _firstName);

cmd.Parameters.AddWithValue("@middleName", _middleName);

cmd.Parameters.AddWithValue("@lastName", _lastName);

cmd.Parameters.AddWithValue("@hireDate", _hireDate);

cmd.Parameters.AddWithValue("@hireDateNumber", _hireDateNumber);

cmd.Parameters.AddWithValue("@active", _active);

cmd.Parameters.AddWithValue("@enteredBy", Csla.ApplicationContext.User.Identity.Name);

cmd.Parameters.AddWithValue("@enteredDateTime", DateTime.Now);

SqlParameter parm = new SqlParameter("@newID", SqlDbType.UniqueIdentifier);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

parm = new SqlParameter("@newLastChanged", SqlDbType.Timestamp);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

cmd.ExecuteNonQuery();

_lastChanged = (byte[])cmd.Parameters["@newLastChanged"].Value;

_employeeID = (Guid)cmd.Parameters["@newID"].Value;

cmd.CommandText = "InsertEmployeeOwner2";

cmd.Parameters.Clear();

cmd.Parameters.AddWithValue("@ownerID", _ownerID);

cmd.Parameters.AddWithValue("@employeeID", _employeeID);

cmd.ExecuteNonQuery();

}

This code is handled as a transaction. The first table rolls back when the second table fails.

protected override void DataPortal_Insert()

{

using (TransactionScope ts = new TransactionScope())

{

SqlConnection con = new SqlConnection(DataConnection.ConnectionString);

con.Open();

SqlCommand cmd = con.CreateCommand();

cmd.CommandType = CommandType.StoredProcedure;

cmd.CommandText = "InsertEmployee";

cmd.Parameters.AddWithValue("@firstName", _firstName);

cmd.Parameters.AddWithValue("@middleName", _middleName);

cmd.Parameters.AddWithValue("@lastName", _lastName);

cmd.Parameters.AddWithValue("@hireDate", _hireDate);

cmd.Parameters.AddWithValue("@hireDateNumber", _hireDateNumber);

cmd.Parameters.AddWithValue("@active", _active);

cmd.Parameters.AddWithValue("@enteredBy", Csla.ApplicationContext.User.Identity.Name);

cmd.Parameters.AddWithValue("@enteredDateTime", DateTime.Now);

SqlParameter parm = new SqlParameter("@newID", SqlDbType.UniqueIdentifier);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

parm = new SqlParameter("@newLastChanged", SqlDbType.Timestamp);

parm.Direction = ParameterDirection.Output;

cmd.Parameters.Add(parm);

cmd.ExecuteNonQuery();

_lastChanged = (byte[])cmd.Parameters["@newLastChanged"].Value;

_employeeID = (Guid)cmd.Parameters["@newID"].Value;

cmd.CommandText = "InsertEmployeeOwner2";

cmd.Parameters.Clear();

cmd.Parameters.AddWithValue("@ownerID", _ownerID);

cmd.Parameters.AddWithValue("@employeeID", _employeeID);

cmd.ExecuteNonQuery();

ts.Complete();

}

}

ajj3085 replied on Monday, September 17, 2007

Could you attach a small sample project that reproduces the issue?  Please include any Sql scripts necessary to build the db.

Andy

dmccrory replied on Monday, September 17, 2007

I would but I am embarrased to say that I don't know how to attach anything to posts in this forum......lol

-DavidBig Smile [:D]

ajj3085 replied on Monday, September 17, 2007

When composing a post, click on the Options tab.  there's a button there to attach files.

dmccrory replied on Monday, September 17, 2007

Crap!!....how embarrasing....I never saw the tabs above.......ok I will zip it up and attach it as soon as I get a chance.....

dmccrory replied on Monday, September 17, 2007

I have attached the files, including the 3 stored procedures. Let me know if there is anything else you need to look at.

Thanks,

-David

ajj3085 replied on Monday, September 17, 2007

Ok, I see the problem.

The problem is that youv'e set your EmployeeTest as a Child object.   attributes won't do anything there when saving or updating (because that can only be done on the parent collection, EmployeeListTest).  The DP has already executed the root DP_U (which in your case is EmployeeTestList) to see if transactions shoudl apply.  Since there is no attribute on your BLB classes DP_U, csla assumes manual.  In your BLB, apply the TransactionScope attribute to the DP_U method there, and remove it from your EmployeeTest class (attributes will have no affect there, since you cannot call Save on the EmployeeTest class).

Note that your entire collection will be saved or rolled back as a group.  If you want the EmployeeTest instances to be savable outside the root (ie, they will be root as in they manage their own transactions),you'll need to use EditableRootListBase.



HTH
Andy

dmccrory replied on Monday, September 17, 2007

Andy,

Thanks for taking the time to examine and explain the problem. I have made the changes and I am now getting the results as you described. This will go a long way to helping me understand this part of the framework.

As for your note...In this case, I think I will need to examine the EditableRootListBase as, although there is a collection of employees, each employee really stands on it's own.

Thanks again, you have been a big help Party!!! [<:o)]

-David

ajj3085 replied on Tuesday, September 18, 2007

One thing to keep in mind; a "root" object is one on which Save will be called, and can usually be fetched on its own.  Its child objects cannot exist outside the context of the root, and it doesn't make sense to edit them outside the context of the root object.  A root and its children will always be part of a single transaction.

That certainly seems to be the case if you're talking about editing an Employee.  I would think that each change to an employee would be independant of editing other employees.  The only reason to use EditableRootListBase would be to allow editing of employees in a grid setting on Windows Forms.  That's the intended use of ERLB and you probably won't use it in any other case. 

Also, if you're using ERLB, you need to remove the MarkAsChild call in the EmployeeTest's constructor.

Glad you're on the right track; if you haven't done so already, you should probably get Rocky's book.  Chapter 8 talks about use case driven business object design, and this has helped me greatly figuring our which Csla framework objects to use as base classes.

HTH
Andy

dmccrory replied on Tuesday, September 18, 2007

So maybe I am still off a little. I am displaying the list in a grid, but editing in an edit form. I have read the book (and bought the handbook today) but I will re-read it again and see if it is clearer now.

The one thing that always confuses me is when objects are part of a collection. If I have a collection of employees that belong to a specific client. I am assuming that the client is the root and the employees are children of the client. But each employee is also an indepent object. I need to re-visit this in the book now that I have spent some time with the framework.

Thanks again for all of your help

-David

ajj3085 replied on Wednesday, September 19, 2007

Ahh, if you have a list just for display, you should have an EmployeeList and EmployeeInfo, which inherit ReadOnlyListBase and ReadOnlyBase respectively.  Employee would inherit BusinessBase and likely be a root object. 

The client can be your root, which could include an EmployeeList instance.  It depends on your use case. Do you need client information and the list at the same time? 

HTH
Andy

dmccrory replied on Wednesday, September 19, 2007

I re-read chapter 8 last night and came to the same conclusion. I understood the "add" and "edit" functions because the user was presented with an edit screen and the root object (employee) was saved as expected. But the "delete" function was confusing me. The user selects a record from the grid to delete and a confirmation box is all that is displayed. If confirmed, I was able to delete the employee object, but I could not remove it from the list because it was not a child.

I have changed this to match the scenario you describe. I have a Client object (root), an EmployeeInfo object (child), an EmployeeList object (collection of EmployeeInfo objects) and an Employee object (root). If the user wants to add or edit an employee, the root employee object is retrieved and diplayed in an edit screen. If the user wants to delete an employee, the Employee object is deleted and the EmployeeInfo object is deleted from the EmployeeList. I think this is how this is supposed to work.

I do have one question though. In my original design, Employee was a root object and EmployeeList was a collection of Employees. I could use Add() to add an Employee to EmployeeList, but I could not use Remove() to remove an Employee from EmployeeList because Employee was not a child. Shouldn't that work the same either way? Why can I add an object that is not a child, but I cannot remove an object that is not a child?

Copyright (c) Marimer LLC